Skip to content

Commit b7433d9

Browse files
ci(docker-publish): a rebuild of an older version keeps latest, X.Y and X where they are (#27102)
Fixes #26961 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手动重建(`workflow_dispatch`)旧版本镜像时,`latest`、`X.Y`、`X` 这三个滚动标签不会再被拉回旧镜像;每个滚动标签只在该版本是 npm 上它那个范围内最新的稳定版时才移动。 - **为什么改**:以前在 18.2.0 是现行版本时重建 17.7.0,会把 `latest` 指回 17.7.0,所有拉 `:latest` 的用户下次拉取就会倒退一个大版本。 - **风险与回滚**:正式发版、或重建当前最新版本时,推送的标签列表与改前逐字节相同;读不到 npm 时,手动重建只推精确版本标签,发版通道照旧移动全部滚动标签。回滚就是 revert 这个 PR,无数据迁移。 - **先看**:`.github/workflows/docker-publish.yml` 里 `Compute image tags` 新增的 `flavor:` 一行(`latest` 有两个来源,只关一个关不住);新步骤的 `LANE` 表达式;`scripts/ci/docker-rolling-tags.mjs` 的 `decide()`。 - **两分钟验证**:`node scripts/ci/docker-rolling-tags.mjs --self-test`,预期输出 `✓ docker-rolling-tags --self-test: 16 cases, 20 CLI runs against a fake npm.` - **需要你做的**:审阅并合并(Tier H:`.github/workflows/**`)。 ## What changes `.github/workflows/docker-publish.yml`, within the card's file surface: - **A new step, `Decide which rolling tags this version may move`** (`id: rolling`), placed after the npm wait step. It runs `scripts/ci/docker-rolling-tags.mjs --self-test`, then `--version "$VERSION" --lane "$LANE"`, and writes `latest`, `minor` and `major` (each `true`/`false`) plus a one-line `reason` to the step outputs and the step summary. - **`Compute image tags`** reads those outputs. `{{major}}.{{minor}}` gets `enable=minor`, `{{major}}` gets `enable=major`, and the raw `latest` line gets `enable=latest`. A new `flavor: latest=` takes the same `latest` output. `scripts/ci/docker-rolling-tags.mjs` (new, dependency-free) implements the rule. A rolling tag moves only when no stable `@objectstack/cli` version on npm in its range is newer than VERSION: | tag | moves when | |---|---| | `latest` | no stable version on npm is newer | | `X.Y` | no stable `X.Y.*` is newer | | `X` | no stable `X.*.*` is newer | A prerelease VERSION moves none of them, as before. Prereleases on npm never hold a rolling tag back. npm is read once with `npm view @objectstack/cli versions --json`. **Fail directions** (PM hypothesis 3): - **Dispatch lane:** when npm's list cannot be read, is not JSON, is empty, or does not contain VERSION (so it may be stale), only the exact tag is pushed. A `::warning::` and the step summary say so. - **Release lane:** a registry hiccup keeps today's behaviour (every rolling tag moves). A list that lags a fresh publish is expected, so VERSION is compared as if it were listed. **Why `latest` needs two switches.** Read from `docker/metadata-action` at the pinned `dc802804`, `src/meta.ts`: - With the default `flavor: latest=auto`, the first stable `type=semver` line sets `version.latest = true` (`procSemver`, L183–198; `setVersion` first-wins, L357–369). - `generateTags` then appends `latest` (L525–527) on top of the raw line's `latest`. - So gating only the raw line, as the card and the suggested route assumed, would still move `latest`. - Both now read one output. Any value other than `true`/`false` makes the action throw (L54–57; `flavor.ts` L40–41). A missing output therefore fails `Compute image tags` loudly instead of guessing. **Why the lane comes from `github.workflow_ref`, not `github.event_name`** (PM hypothesis 3, falsified as worded): - In a `workflow_call` run the `github` context is the caller's. - `release.yml` has its own `workflow_dispatch` repair lane (L208). Its `release-integrity` job (`if:` at L959–961) feeds the `docker` job (L2071–2095), which calls this workflow. - So a real release can arrive here with `event_name == 'workflow_dispatch'`. - `github.workflow_ref` names `docker-publish.yml` only when this file was dispatched directly. ## Tests `node scripts/ci/docker-rolling-tags.mjs --self-test` gives `✓ docker-rolling-tags --self-test: 16 cases, 20 CLI runs against a fake npm.` (exit 0). - Every row spawns the real CLI with a fake `npm` as the only PATH entry, plus throwaway `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` files. - Each row asserts: - the three outputs; - the `::warning::` line, present or absent; - the step summary; - the exact argv npm was called with, or that npm was not called. - The self-test copies the battery-roster floor (16) and the verdict handshake from `scripts/ci/nightly-tiers-card.mjs`. **Per-scenario tag list.** This is a one-time proof, not a committed test. The new step's `run:` body was extracted from the YAML with PyYAML (not retyped) and run under `bash -e` with a fake `npm` on PATH, at head `7b36a647`. npm's list was `17.6.0, 17.6.1, 17.7.0, 18.0.0, 18.1.2, 18.1.3, 18.2.0, 19.0.0-rc.1`. The before/after columns model the pinned metadata-action's tag assembly from the source lines cited above; the action itself was not executed (see Acceptance notes). Image prefix omitted. | scenario | VERSION | lane | step exit | tags before | tags after | |---|---|---|---|---|---| | rebuild of the current version | `18.2.0` | dispatch | 0 | 18.2.0, 18.2, 18, latest, latest | 18.2.0, 18.2, 18, latest, latest | | release of the newest version | `18.2.0` | release | 0 | 18.2.0, 18.2, 18, latest, latest | 18.2.0, 18.2, 18, latest, latest | | rebuild of the newest 17.x (the card's example) | `17.7.0` | dispatch | 0 | 17.7.0, 17.7, 17, latest, latest | 17.7.0, 17.7, 17 | | rebuild of an older 17.6 patch | `17.6.0` | dispatch | 0 | 17.6.0, 17.6, 17, latest, latest | 17.6.0 | | rebuild of the newest patch of an older minor | `18.1.3` | dispatch | 0 | 18.1.3, 18.1, 18, latest, latest | 18.1.3, 18.1 | | rebuild of an older patch (the card's 18.1 example) | `18.1.2` | dispatch | 0 | 18.1.2, 18.1, 18, latest, latest | 18.1.2 | | prerelease | `19.0.0-rc.1` | dispatch | 0 | 19.0.0-rc.1 | 19.0.0-rc.1 | | npm unreadable | `17.7.0` | dispatch | 0 | 17.7.0, 17.7, 17, latest, latest | 17.7.0 | | npm unreadable | `18.2.0` | release | 0 | 18.2.0, 18.2, 18, latest, latest | 18.2.0, 18.2, 18, latest, latest | | release while npm's list lags the publish | `18.3.0` | release | 0 | 18.3.0, 18.3, 18, latest, latest | 18.3.0, 18.3, 18, latest, latest | <details><summary>reason line per scenario, as the step wrote it</summary> ```text 18.2.0 dispatch read 8 versions from npm; moves latest, 18.2, 18 18.2.0 release read 8 versions from npm; moves latest, 18.2, 18 17.7.0 dispatch read 8 versions from npm; moves 17.7, 17; stays: latest (18.2.0 is newer) 17.6.0 dispatch read 8 versions from npm; moves no rolling tag; stays: latest (18.2.0 is newer), 17.6 (17.6.1 is newer), 17 (17.7.0 is newer) 18.1.3 dispatch read 8 versions from npm; moves 18.1; stays: latest (18.2.0 is newer), 18 (18.2.0 is newer) 18.1.2 dispatch read 8 versions from npm; moves no rolling tag; stays: latest (18.2.0 is newer), 18.1 (18.1.3 is newer), 18 (18.2.0 is newer) 19.0.0-rc.1 19.0.0-rc.1 is a prerelease, so it gets only its exact tag 17.7.0 dispatch `npm view @objectstack/cli versions --json` exited 1; dispatch lane, so only the exact tag 17.7.0 is pushed and no rolling tag moves 18.2.0 release `npm view @objectstack/cli versions --json` exited 1; release lane, so every rolling tag moves as it did before this check existed 18.3.0 release read 8 versions from npm; moves latest, 18.3, 18 ``` </details> The doubled `latest` in today's list is what the source assembles (raw line plus `latest=auto`). It is kept on purpose, so the newest-version rows are identical before and after. ## Acceptance notes - **The self-test runs inside the step, not on pull requests.** - `check:self-test-wired` requires a self-test that a workflow names to be run by some workflow, and the step runs it right before the decision takes effect. - The pull-request home for such self-tests is `lint.yml`'s `Moved workflow step bodies self-test` collector. That file is outside this card's file surface, so this PR does not touch it. Wiring it there is a one-line follow-up for the seat. - Until then, a broken script is caught when the docker job runs, which fails before any tag is computed. - **The two lanes rest on GitHub's documented `workflow_call` context semantics.** This was not measured: by ruling, the workflow is not dispatched or re-run to prove it. - If `github.workflow_ref` named this file in a called run too, the release lane would take the dispatch fail direction. - That changes behaviour only when npm is unreadable, and then it holds `latest` back with a warning. It never moves `latest` wrongly. - **Older lines.** Rebuilding the newest version of an older line (17.7.0 while 17.7.0 is the newest 17.x) still moves that line's own `17.7` and `17`, as the card's done criterion asks; it never moves `latest`. PM hypothesis 4's "older major: none move" holds when VERSION is not the newest in its line (row `17.6.0`). - **npm data could not be reused.** The npm wait step (`5eeb02c6`) reads per-version manifests and tarballs, not the version list, so the new step makes its own single read. - **The release lane gets the rule too.** `release-integrity`'s image backfill builds main's version. If that version were ever older than npm's newest stable, the backfill would no longer move `latest`. - **NOT MEASURED: `actionlint`.** It is not on PATH in this container. The edited file parses with PyYAML, and the workflow gates below pass. - **NOT MEASURED: running the pinned action.** Running the downloaded `docker/metadata-action` `dist` offline was refused by this session's permission classifier, so the before/after tag lists model its source rather than its output. ## Verification All derived families were run locally at head `7b36a647`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 48 commands. - All 48 exited 0. - `--ran` reconciliation: `48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED (a DERIVED zero — all 48 recorded an exit code and none of them is 3)`. <details><summary>verdict lines</summary> ```text check-self-test-wired: every one of the 241 script(s) CI runs that ship a `--self-test` has that self-test run by CI check-self-test-workflow-commands: no self-test CI runs prints a line the Actions runner would parse as a workflow command. check-step-collectors: 588 `run:` steps across 30 workflow(s) and 3 composite action(s); 5 step(s) run 2+ independent self-tests, all of them through a collector. check-workflow-status-functions: OK (scanned 30 workflow file(s), 77 job(s), 45 job-level if: expression(s); 17 read needs.*.outputs.*, all naming a status function). check-workflow-step-name-quoting: OK (scanned 30 workflow file(s) + 3 composite action file(s), 823 step name(s) -- 8 already quoted, 815 unquoted-and-safe). check-nul-bytes: OK (scanned 21802 text file(s) ... no raw ASCII control bytes). check-expected-skips self-test: 206 cases pass dispatch-gates self-test (fast tier): 1935 cases pass; 7 slow section(s) deferred. (check:pm-dispatch-gates, EXIT=0) docker-rolling-tags --self-test: 16 cases, 20 CLI runs against a fake npm. ``` </details> - No package publishes anything here: `scripts/` belongs to the private root package, so this PR carries `skip-changeset`. - No commit on `origin/main` since the base `fbc16563` touched `docker-publish.yml` or `scripts/ci/`. - ⛔ The workflow was not dispatched or re-run (Prime Directive 15). --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmhU5KEj1nukYhp1Tg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50b1b7d commit b7433d9

3 files changed

Lines changed: 510 additions & 6 deletions

File tree

‎.github/workflows/docker-publish.yml‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,31 @@ jobs:
127127
console.log(`npm serves @objectstack/cli@${version} and ${seen.size - 1} @objectstack/* dependencies.`);
128128
NODE
129129
130+
- name: Decide which rolling tags this version may move
131+
# A rebuild of an older version must not move `latest`, `X.Y` or `X`
132+
# back onto it. Each rolling tag moves only when no stable
133+
# @objectstack/cli version on npm in that tag's range is newer, so a
134+
# release, or a rebuild of the current version, moves all three as
135+
# before. When npm cannot be read, a dispatch pushes only the exact tag,
136+
# and the release lane moves every rolling tag as before. The rules,
137+
# and both fail directions, are in the script's header (#26961).
138+
#
139+
# LANE cannot come from `github.event_name`. In a workflow_call run the
140+
# `github` context is the CALLER's, and release.yml's repair lane is a
141+
# workflow_dispatch too. `github.workflow_ref` names this file only
142+
# when it was dispatched directly.
143+
#
144+
# The self-test runs first. A pull request never runs this step, so
145+
# this is the last point where the decision can be checked before it
146+
# takes effect.
147+
id: rolling
148+
env:
149+
VERSION: ${{ steps.version.outputs.version }}
150+
LANE: ${{ contains(github.workflow_ref, '/.github/workflows/docker-publish.yml@') && 'dispatch' || 'release' }}
151+
run: |
152+
node scripts/ci/docker-rolling-tags.mjs --self-test
153+
node scripts/ci/docker-rolling-tags.mjs --version "$VERSION" --lane "$LANE"
154+
130155
- name: Set up QEMU (arm64 emulation)
131156
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
132157

@@ -145,13 +170,22 @@ jobs:
145170
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
146171
with:
147172
images: ${{ env.IMAGE }}
148-
# Tag = CLI version. Rolling minor/major/latest tags move with every
149-
# publish; prereleases (x.y.z-rc.1) get only their exact tag.
173+
# Tag = CLI version. The rolling minor/major/latest tags move only as
174+
# far as the step above allows; prereleases (x.y.z-rc.1) get only
175+
# their exact tag.
176+
#
177+
# `latest` has TWO sources in this action: the raw line below, and
178+
# `flavor: latest=auto` (the default), which adds `latest` by itself
179+
# for any stable `type=semver` value. Both read the same output, so
180+
# gating only one of them still moves `latest`. For the newest
181+
# version the tag list is exactly what it was before this change.
182+
flavor: |
183+
latest=${{ steps.rolling.outputs.latest }}
150184
tags: |
151185
type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}
152-
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.version }}
153-
type=semver,pattern={{major}},value=${{ steps.version.outputs.version }}
154-
type=raw,value=latest,enable=${{ !contains(steps.version.outputs.version, '-') }}
186+
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.version }},enable=${{ steps.rolling.outputs.minor }}
187+
type=semver,pattern={{major}},value=${{ steps.version.outputs.version }},enable=${{ steps.rolling.outputs.major }}
188+
type=raw,value=latest,enable=${{ steps.rolling.outputs.latest }}
155189
156190
- name: Build and push
157191
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0

‎.github/workflows/lint.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3422,14 +3422,15 @@ jobs:
34223422
}
34233423
run_self_test node scripts/ci/scheduled-full-run-card.mjs --self-test
34243424
run_self_test node scripts/ci/nightly-tiers-card.mjs --self-test
3425+
run_self_test node scripts/ci/docker-rolling-tags.mjs --self-test
34253426
run_self_test bash scripts/ci/board-snapshot-r2-upload.selftest.sh
34263427
if [ -n "$failed" ]; then
34273428
echo ""
34283429
echo "Moved workflow step bodies self-tests — the following FAILED:"
34293430
printf "%s" "$failed"
34303431
exit 1
34313432
fi
3432-
echo "Moved workflow step bodies self-tests — all three ran and passed"
3433+
echo "Moved workflow step bodies self-tests — all four ran and passed"
34333434
34343435
# Gate-family selection self-test (#16496). The "Select the gate families
34353436
# this run pays for" step at the top of this job is a thin call into

0 commit comments

Comments
 (0)