Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 44 additions & 37 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1093,37 +1093,36 @@ jobs:
[ "$IOS" = success ] || { echo "::error::full device suite (ios) did not succeed on an upstream bump: $IOS"; rc=1; }
exit "$rc"

# Keep refs/heads/upstream-base on this fork pointing at the tag in
# Verify refs/heads/upstream-base on this fork points at the tag pinned in
# upstream-base.txt. publish tags a tree materialized from a depth-1 clone of
# nodejs/node, so the pack it sends names the base commit's parent without
# carrying it; the remote's connectivity check rejects the tag ("did not
# receive expected object") unless it already holds that ancestry. This job
# is what makes it hold — the branch both delivers the objects and keeps them
# reachable, so no later GC can take them back.
# receive expected object") unless it already holds that ancestry. The
# upstream-base branch is what makes it hold -- it both delivers the objects
# and keeps them reachable, so no later GC can take them back.
#
# It runs on every push rather than only on a bump: the ls-remote comparison
# costs a second and no-ops, and gating on a "did this push move the file"
# derivation would rest the invariant on that derivation being right. Running
# at bump-merge time also keeps the full clone off the release run's critical
# path, where a failure costs a 3-4 hour rebuild to retry.
# Moving that branch is a manual maintainer step, deliberately not done here.
# The push carries upstream's own .github/workflows/ files, and GitHub
# refuses those from an App token (which GITHUB_TOKEN is) unless it holds the
# `workflows` permission -- a scope no `permissions:` block can grant. Doing
# it in CI therefore means storing a PAT or deploy key with write access to
# this repo, which is not worth the exposure for a push taken once per
# upstream bump. So this job only checks, and prints the exact commands.
#
# Skipped on pull_request: it wants contents:write, which this workflow
# deliberately keeps away from PR runs. A bump PR therefore gets no signal
# here, but its merge to recipe pushes the base long before any release run.
# It runs on every event, PRs included: the check is two ls-remotes, and a
# bump PR is exactly where the maintainer wants to learn the base still
# needs pushing -- long before a release run gets as far as tagging.
upstream-base:
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 10
permissions:
contents: write # pushes refs/heads/upstream-base
env:
GH_TOKEN: ${{ github.token }}
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 1

- name: Mirror the pinned upstream base onto this fork
- name: Check the pinned upstream base is on this fork
run: |
set -euo pipefail
# || true: grep exits 1 on a comments-only file, which under pipefail
Expand All @@ -1140,26 +1139,33 @@ jobs:
refs/heads/upstream-base | cut -f1)

if [ "$WANT" = "$HAVE" ]; then
echo "upstream-base already at ${BASE} (${WANT})"
echo "upstream-base is at ${BASE} (${WANT})"
exit 0
fi

# --single-branch: the base tag's history alone, not every release
# line. The push that follows is small whenever the fork already
# carries an older base — git sends only the delta between them.
git clone --bare --single-branch --branch "$BASE" \
"$UPSTREAM" "$RUNNER_TEMP/upstream.git"
# --force: a new base need not descend from the old one (it does not
# across a major). Superseded bases stay reachable from the release
# tags built on them, so nothing is orphaned.
#
# --no-follow-tags: never let upstream's own vX.Y.Z tag ride along.
# It would collide with this fork's vX.Y.Z-R release tag namespace
# and can be refused outright by a tag ruleset, failing the push.
git -C "$RUNNER_TEMP/upstream.git" push --force --no-follow-tags \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
"${WANT}:refs/heads/upstream-base"
echo "Pushed upstream-base -> ${BASE} (${WANT})"
echo "::error::upstream-base is not at ${BASE}; a maintainer must push it before a release can be tagged (see docs/UPGRADING.md)"
cat >&2 <<EOF

upstream-base is at: ${HAVE:-(no such branch)}
it must be at: ${WANT} (${BASE})

Moving it is a manual step -- the push rewrites upstream's own
.github/workflows/ files, which GITHUB_TOKEN is not permitted to do,
and this workflow holds no credential that is. From a clone of this
fork, with a remote you can push to:

git fetch https://github.com/nodejs/node.git refs/tags/${BASE}
git push --force --no-follow-tags <remote> ${WANT}:refs/heads/upstream-base

then re-run this job.

--force because a new base need not descend from the old one (it does
not across a major); superseded bases stay reachable from the release
tags built on them, so nothing is orphaned. --no-follow-tags keeps
upstream's own ${BASE} tag out of this fork's vX.Y.Z-R tag namespace,
where it would collide and can be refused by a tag ruleset.
EOF
exit 1

# Publish: on a release commit, once every gate in this run is green, tag
# the materialized tree and publish the GitHub prerelease with this run's
Expand Down Expand Up @@ -1215,8 +1221,9 @@ jobs:
# Note: the materialized tree is a SHALLOW clone of nodejs/node (prepare.sh
# uses --depth 1), so this pushes a tag whose history is grafted at the
# upstream base, and git sends only the commits above that boundary. The
# remote accepts it only because the upstream-base job (needs:, above) has
# already put the base and its ancestry there.
# remote accepts it only because the base and its ancestry are already
# there, on the upstream-base branch that the upstream-base job (needs:,
# above) has verified.
- name: Tag the materialized tree
run: |
git config user.name "nodejs-mobile-bot"
Expand Down
9 changes: 5 additions & 4 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ Releasing is a button, a review, and (optionally) an approval:
One of those needs is `upstream-base`, which is plumbing rather than a
gate. The tag is pushed from a tree materialized by a depth-1 clone, so
its history stops at the upstream base and the remote will only accept it
if it already holds that base and everything under it. The job keeps this
fork's `upstream-base` branch on the pinned tag, which puts those objects
there and keeps them reachable. It no-ops in a second unless the base
moved, so it costs nothing except on the run after an upgrade.
if it already holds that base and everything under it. This fork's
`upstream-base` branch is what puts those objects there and keeps them
reachable, and the job checks it is on the pinned tag. Moving it is a
manual step taken once per upgrade, so on a release run this only ever
confirms what the upgrade already did.
→ [UPGRADING.md](./UPGRADING.md)
4. **Optional human gate:** the publish job runs in the `release`
Environment. Add required reviewers under Settings → Environments →
Expand Down
29 changes: 22 additions & 7 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,28 @@ gate is an allow-list, so it cannot see tests upstream just added — they
would otherwise surface on the nightly or at release). Expect the PR to take
substantially longer than a normal one. → [TESTING.md](./TESTING.md)

Merging the bump also moves this fork's `upstream-base` branch to the new
tag, via the `upstream-base` job. That branch is load-bearing rather than
informational: the release tag is pushed from a shallow clone whose history
stops at the base, so the remote can only accept it if it already holds the
base and its ancestry. Don't delete the branch, and if the job fails, fix it
before cutting a release — `publish` `needs:` it, so a release run would
otherwise get as far as tagging and stop there.
A bump also needs this fork's `upstream-base` branch moved to the new tag.
That branch is load-bearing rather than informational: the release tag is
pushed from a shallow clone whose history stops at the base, so the remote
can only accept it if it already holds the base and its ancestry. Don't
delete it, and don't leave it behind — `publish` `needs:` the `upstream-base`
job, so a release run would otherwise get as far as tagging and stop there.

Moving it is a manual maintainer step. CI only checks it, because the push
carries upstream's own `.github/workflows/` files and GitHub refuses those
from `GITHUB_TOKEN` without the `workflows` permission — a scope a workflow
cannot grant itself. Automating it would mean giving CI a PAT or deploy key
with write access to this repo, which is not worth the exposure for a push
taken once per upgrade. The `upstream-base` job prints the exact commands
when it fails; they amount to:

```sh
git fetch https://github.com/nodejs/node.git refs/tags/v24.20.0
git push --force --no-follow-tags digidem <sha-of-v24.20.0>:refs/heads/upstream-base
```

Push it any time from the bump PR onwards — nothing reads the branch except
a release run — then re-run the job.

## Release

Expand Down
Loading