Skip to content

Commit da5fffa

Browse files
committed
Create a pinned tag once and never move it
README.md says v<date>, <alias>-v<date> and <alias>-<anchor> never move. The publish job created every tag on every build, and only the backup cron stood down when the day's v<date> existed. So v2026.08.26 was created by five real builds and v2026.08.28 by three, and every <alias>-<anchor> moved each day while pacman kept one version. Nine repositories outside this one pin a dated index, among them three VSCodium release workflows. The publish job now reads the target's tag list with scripts/registry-tags before it creates a tag, and both tag steps pass it to scripts/tag-names --existing, which leaves out each pinned tag already on it and names it. A list that cannot be read stops the publish. latest and <alias> move as before. The verify step reads the platforms of latest, which the run always writes, and requires the dated index to exist. Dry run 38675434990, the second build of 2026-10-15 in the scratch repository: every rolling tag moved, and all 21 pinned tags of the five ports and the index kept their digests. Measurements and what is not verified: HISTORY/pinned-tags.md.
1 parent ccfe903 commit da5fffa

9 files changed

Lines changed: 434 additions & 32 deletions

File tree

‎.github/workflows/build-deploy.yml‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,21 @@ jobs:
699699
username: ${{ secrets.DOCKERHUB_USERNAME }}
700700
password: ${{ secrets.DOCKERHUB_TOKEN }}
701701

702+
# ⛔ A pinned tag is created once and never pointed anywhere else:
703+
# v<date>, <alias>-v<date> and <alias>-<anchor>. The two steps below pass
704+
# this list to scripts/tag-names, which leaves out each pinned tag already
705+
# on it. So a second build on one day, or with one pacman version, moves
706+
# latest and <alias> only. GHCR is read because every digest is staged
707+
# there and both registries get their tags from one command. A list that
708+
# cannot be read stops the job, because read as empty it would move every
709+
# pinned tag. HISTORY/pinned-tags.md.
710+
- name: Read the tags the repository already carries
711+
env:
712+
TARGET_IMAGE: ${{ needs.resolve.outputs.target_image }}
713+
run: |
714+
set -euo pipefail
715+
scripts/registry-tags ghcr.io "${TARGET_IMAGE#ghcr.io/}" > /tmp/existing-tags.txt
716+
702717
# Only for what built in this run. A carried architecture's tags already
703718
# point at the image it carries, so they are left exactly where they are.
704719
- name: Create the per architecture tags
@@ -721,7 +736,13 @@ jobs:
721736
continue
722737
fi
723738
anchor="$(jq -er --arg a "$arch" '.[$a]' <<< "$ANCHORS")"
724-
mapfile -t tags < <(scripts/tag-names arch "$arch" "$VERSION" "$anchor" "${images[@]}")
739+
mapfile -t tags < <(scripts/tag-names --existing /tmp/existing-tags.txt arch "$arch" "$VERSION" "$anchor" "${images[@]}")
740+
# mapfile does not see the exit status of tag-names. The rolling
741+
# tags are always printed, so an empty list means it failed.
742+
if [ "${#tags[@]}" -eq 0 ]; then
743+
echo "scripts/tag-names printed no tag for $arch, and its reason is above" >&2
744+
exit 1
745+
fi
725746
args=()
726747
for t in "${tags[@]}"; do
727748
args+=(-t "$t")
@@ -748,7 +769,11 @@ jobs:
748769
echo "the plan names no image, so there is no index to create" >&2
749770
exit 1
750771
fi
751-
mapfile -t tags < <(scripts/tag-names index "$VERSION" "${images[@]}")
772+
mapfile -t tags < <(scripts/tag-names --existing /tmp/existing-tags.txt index "$VERSION" "${images[@]}")
773+
if [ "${#tags[@]}" -eq 0 ]; then
774+
echo "scripts/tag-names printed no index tag, and its reason is above" >&2
775+
exit 1
776+
fi
752777
args=()
753778
for t in "${tags[@]}"; do
754779
args+=(-t "$t")
@@ -771,18 +796,27 @@ jobs:
771796
# fail on its own, and until this step covered it a Docker Hub tag that
772797
# never arrived, or arrived without every platform, ended a green run.
773798
verify_index() {
774-
local image="$1" dated got expect arch
775-
# The dated index tag carries a leading v. scripts/tag-names is the
776-
# authority on the spelling, so it is asked rather than
777-
# reconstructed: inspecting a tag the publish job never created
778-
# would fail every run.
799+
local image="$1" latest dated got expect arch
800+
# scripts/tag-names is the authority on the spelling, so it is
801+
# asked rather than reconstructed: inspecting a tag the publish job
802+
# never created would fail every run.
803+
latest="$(scripts/tag-names index "$VERSION" "$image" | grep ':latest$')"
779804
dated="$(scripts/tag-names index "$VERSION" "$image" | grep -v ':latest$')"
780-
echo "verifying $dated"
805+
806+
# ⛔ latest is the index this run wrote. The dated index is pinned:
807+
# the first build of the day creates it and a later one leaves it,
808+
# so its platforms are that build's. It has to exist, whichever
809+
# run created it.
810+
if ! docker buildx imagetools inspect "$dated" > /dev/null; then
811+
echo "$dated does not exist after the publish" >&2
812+
exit 1
813+
fi
814+
echo "verifying $latest"
781815
782816
# The index must carry every platform the plan put in it, built or
783817
# carried. An index with a hole in it is the failure this whole
784818
# topology exists to prevent.
785-
got="$(docker buildx imagetools inspect "$dated" --raw \
819+
got="$(docker buildx imagetools inspect "$latest" --raw \
786820
| jq -r '[ .manifests[].platform
787821
| select(.os != "unknown")
788822
| .architecture + (if .variant then "/" + .variant else "" end) ]
@@ -799,14 +833,14 @@ jobs:
799833
continue
800834
fi
801835
if ! grep -qw -- "$expect" <<< "$got"; then
802-
echo "the index for $dated is missing $expect" >&2
836+
echo "the index for $latest is missing $expect" >&2
803837
echo "it carries: $got" >&2
804838
echo "the digests were staged on ${TARGET_IMAGE}. If that index is" >&2
805839
echo "complete and this one is not, the cross-registry copy is what failed." >&2
806840
exit 1
807841
fi
808842
done
809-
echo "::notice::published $dated with platforms $got"
843+
echo "::notice::published $latest with platforms $got, and $dated exists"
810844
}
811845
812846
verify_index "$TARGET_IMAGE"

‎HISTORY/CONTINUE.md‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,8 @@ name anyway, the spelling ArchPOWER itself uses: `powerpc`, `powerpc64`,
262262
It is a fork of `fwcd/docker-archlinux` in GitHub's data model only. ⛔ Treat it
263263
as independent. Do not sync from upstream. Do not describe it as a fork.
264264

265-
198 tracked files, 21 scripts, 10 workflows, 35 test files, 7 examples,
266-
54 documents under `HISTORY/`. Measured 2026-10-15 at this session's last
265+
199 tracked files, 21 scripts, 10 workflows, 35 test files, 7 examples,
266+
55 documents under `HISTORY/`. Measured 2026-10-15 at this session's last
267267
commit, with `git ls-files | wc -l`,
268268
`ls scripts`, `ls .github/workflows`, `ls tests/static/*.sh tests/image/*.sh`,
269269
`ls examples | grep -v README` and `find HISTORY -name '*.md'`.
@@ -338,7 +338,7 @@ asserted by `tests/image/50-consumer-contract.sh`.
338338
| `scripts/check-mirror-lag` | `keep`, or `drop <server>` when the first server of one port's list is more than `MIRROR_LAG_MAX` seconds behind the next one, on any section that reads that list. The resolve job runs it for every port before the anchor. `HISTORY/ghcr-mirror.md` |
339339
| `scripts/build-pacman-static` | links a static pacman for one architecture, from source, against the pin |
340340
| `scripts/release-notes` | writes a release body and `SHA256SUMS` from the evidence files beside the assets |
341-
| `scripts/tag-names` | prints the tags for one architecture, or for the index |
341+
| `scripts/tag-names` | prints the tags for one architecture, or for the index. ⛔ With `--existing`, leaves out each pinned tag already in the repository, so a pinned tag is created once and never moves. `HISTORY/pinned-tags.md` |
342342
| `scripts/gen-evidence` | writes the per-platform evidence file |
343343
| `scripts/check-keyring-pin` | exit 0 current, 3 behind, 4 gone from the mirror, 5 bytes changed, 6 unreachable, 7 a newer package no pinned key signed. `--apply` moves the pin and the package in the tree together |
344344
| `scripts/check-image-pins` | every `@sha256:` against its `# tag:` marker. `--apply` rewrites |
@@ -350,7 +350,7 @@ asserted by `tests/image/50-consumer-contract.sh`.
350350
| `scripts/keep-schedules-enabled` | enables every workflow in the tree, so 60 quiet days cannot switch the schedules off |
351351
| `tests/static/` | 29 files, no container needed |
352352
| `tests/image/` | 6 files, against a built image |
353-
| `HISTORY/` | 54 documents, 28 of them reviews. ⛔ never the manual. `CONTINUE.md` is the exception: it is this file |
353+
| `HISTORY/` | 55 documents, 28 of them reviews. ⛔ never the manual. `CONTINUE.md` is the exception: it is this file |
354354
| `README.md`, `docs/` | the manual |
355355

356356
### Running the tests
@@ -720,6 +720,7 @@ manual cross reference remap.
720720
| 58 | Renovate configured in the tree, and its pin bumps approved and merged once CI passes, behind a guard that refuses anything but a hash, a digest or a version comment. Six Renovate pull requests merged by hand first, each hash checked against its tag | `.github/workflows/bot-merge.yml`, `tests/static/89-bot-merge-guard.sh` |
721721
| 59 | Tag lists read page by page, every schedule re-enabled daily from two workflows, the watchdog reading the plain run list it had been misreading, and mirror age and reachability reported rather than gating every build | `scripts/registry-tags`, `scripts/keep-schedules-enabled`, `tests/static/40-mirrors-reachable.sh` |
722722
| 60 | The five ports the GHCR mirror carries read it first, in the build and in the shipped list, with the upstream servers after it. A build compares it with the next server first and builds without it when it is more than 12 hours behind. Three PowerPC anchor files carried markers encoded twice, now repaired, with a test | `HISTORY/ghcr-mirror.md`, `tests/static/66-mirror-lag.sh`, `tests/static/98-text-encoding.sh` |
723+
| 61 | A pinned tag is created once and never moves: `v<date>`, `<alias>-v<date>` and `<alias>-<anchor>`. `v2026.08.26` had been created by five real builds and `v2026.08.28` by three, and every anchor tag moved each day. The publish reads the target's tags first and creates only the pinned ones that are absent; `latest` and `<alias>` move as before | `HISTORY/pinned-tags.md`, `tests/static/60-tag-families.sh`, `tests/static/35-publish-targets.sh` |
723724

724725
---
725726

@@ -850,6 +851,12 @@ architecture path is no longer a hole in anything. What remains to decide is
850851
whether a dispatch input naming one architecture is worth the saved runner
851852
time, given that the others would be rebuilt identically and tagged anyway.
852853

854+
⚠ **A dispatch on a day that already published moves the rolling tags only**,
855+
since 2026-10-15. `latest` and `<alias>` get the rebuilt image; that day's
856+
`v<date>` and `<alias>-v<date>` keep the morning's. A consumer who pinned the
857+
dated tag keeps what they pinned, which is the point of the rule.
858+
`HISTORY/pinned-tags.md`.
859+
853860
### 5. Done. See Completed row 54.
854861

855862
⛔ **The number is kept and the body is gone.**
@@ -1067,6 +1074,13 @@ unexplained; what is new is that a recurrence now turns something red.
10671074

10681075
## Where things stand
10691076

1077+
⭐ **2026-10-15: a pinned tag is created once and never moves.**
1078+
`HISTORY/pinned-tags.md`. `v<date>`, `<alias>-v<date>` and `<alias>-<anchor>`
1079+
had all moved: the dated ones on 2026-08-26 and 2026-08-28, the anchor ones on
1080+
every build. The publish now reads the target's tag list first and creates only
1081+
the pinned tags that are absent. ⚠ The anchor tags that exist stay on the image
1082+
the last build before this change gave them.
1083+
10701084
⭐ **2026-10-15: the five ports the GHCR mirror carries build from it first.**
10711085
`HISTORY/ghcr-mirror.md`. The shipped lists of amd64, arm64, armv7, loong64 and
10721086
riscv64 name `ghcr.mirror.archlinux.pkgforge.dev` first and the upstream
@@ -1078,9 +1092,9 @@ when it is more than 12 hours behind; the image still ships it. In short:
10781092
repository in 441 s, against 544 s for scheduled run `38549236313`. Every
10791093
build step was shorter, riscv64 by a third. One sample each.
10801094
- ⚠ No real tag moved in this session. The first scheduled run after this
1081-
commit is the first real publish from the mirror. ⛔ A dispatch on the same
1082-
day as the primary cron moves that day's dated tags, because only the backup
1083-
cron stands down when `v<date>` exists. Not fixed here.
1095+
commit is the first real publish from the mirror. A dispatch on the same
1096+
day as the primary cron moved that day's dated tags; the entry above closes
1097+
that.
10841098
- The arm64 and armv7 `extra` sections measured 21147 s behind, real lag of the
10851099
mirror's own upstream reads, not of this repository. 43200 is the bound.
10861100

‎HISTORY/pinned-tags.md‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Pinned tags are created once
2+
3+
⭐ **Decided 2026-10-15.** A pinned tag is created by the first build that
4+
names it and is never pointed at another image: `v<date>`, `<alias>-v<date>`
5+
and `<alias>-<anchor>`. `latest` and `<alias>` move with every build. The
6+
maintainer asked for the rule that is easier to maintain. This is it: one rule
7+
for every pinned family, in one place, under one test. The other choice, an
8+
anchor tag that follows the newest build with its pacman version, is a second
9+
rule with its own tests.
10+
11+
## What moved
12+
13+
`README.md` said all three pinned families never move. The publish job created
14+
every tag on every build, and only the backup cron stood down when the day's
15+
`v<date>` existed. So a second real publish on one day moved that day's dated
16+
tags. The runs whose `Create tags` job succeeded, read from their logs:
17+
18+
```bash
19+
gh run list --workflow build-deploy.yml --limit 200 --json databaseId --jq '.[].databaseId'
20+
gh run view RUN --log | grep -E '^Create tags' | grep -oE 'ghcr\.io/pkgforge-dev/archlinux(-ci)?:v[0-9.]+' | sort -u
21+
```
22+
23+
| day | real publishes of `v<date>` | runs |
24+
| --- | --- | --- |
25+
| 2026-08-26 | 5 | `32992678276`, `33003974783`, `33005958600`, `33008010432`, `33009605655`, all dispatches |
26+
| 2026-08-28 | 3 | `33184973002` dispatch, `33195147714` schedule, `33206327128` dispatch |
27+
| every other day with a real publish, 2026-08-29 to 2026-10-14 | 1 | |
28+
29+
⚠ On 2026-08-28 the last of the three added the PowerPC ports, so
30+
`v2026.08.28` went from five platforms to eight. Completed row 42 of
31+
`HISTORY/CONTINUE.md` records that as a result. It was also a pinned tag
32+
moving.
33+
34+
⚠ The anchor tags moved every day, not only on days with two builds. pacman
35+
stayed at one version for weeks, and each build created
36+
`<alias>-7.1.0.r9.g54d9411-2` again. Measured 2026-10-15 on GHCR: `x86_64`,
37+
`x86_64-v2026.10.14` and `x86_64-7.1.0.r9.g54d9411-2` were one digest,
38+
`sha256:690e2a8d99a37a5f64c6d45d78d367f9211f94c4e2bf698e57c59b78ab758275`,
39+
and `x86_64-v2026.10.13` was another. So the anchor tag had moved from the
40+
2026-10-13 image to the 2026-10-14 image.
41+
42+
## Who pins what
43+
44+
GitHub code search, 2026-10-15:
45+
46+
| search | results |
47+
| --- | --- |
48+
| `"pkgforge-dev/archlinux:v2026"` | 11. Nine are outside this repository: three release workflows of `VSCodium/vscodium`, their copies in `crow-cli/crow-cli`, and one each in `stoatchat/for-desktop`, `Chillboio-Studios/Dawnchat-For-DESKTOP` and `edgarcnp/homebrew-tap` |
49+
| `"pkgforge/archlinux:v2026"` | 1, in this repository |
50+
| `"pkgforge-dev/archlinux:x86_64-v2026"` and the Docker Hub name | 0 |
51+
| `"pkgforge-dev/archlinux:x86_64-7"` and the Docker Hub name | 0 |
52+
53+
```bash
54+
gh api -X GET search/code -f q='"pkgforge-dev/archlinux:v2026"' --jq .total_count
55+
```
56+
57+
The dated index is what consumers pin, and a moved one gives a consumer who
58+
pinned it another image. No result pins an anchor tag, so freezing those is a
59+
change nobody found in use.
60+
61+
## How
62+
63+
1. The publish job reads the target repository's tag list with
64+
`scripts/registry-tags`, before it creates a tag. GHCR is read because every
65+
digest is staged there, and one `imagetools create` gives both registries
66+
their tags.
67+
2. Both tag steps pass that list to `scripts/tag-names --existing`, which
68+
leaves out each pinned tag on it and names it on stderr. A tag that is not
69+
passed to `imagetools create` cannot move.
70+
3. The verify step reads the platforms of `latest`, which this run always
71+
writes, and requires the dated index to exist. A dated index from an earlier
72+
build of the day carries that build's platforms.
73+
74+
⛔ A list that cannot be read stops the publish. Read as empty, it would move
75+
every pinned tag. That is the same choice as the anchor floor: a guard that a
76+
transient switches off is not a guard.
77+
78+
The backup cron still stands down when `v<date>` exists. It saves the runner
79+
time; it is no longer what keeps the dated tag in place.
80+
81+
## What it changes for a consumer
82+
83+
| case | before | after |
84+
| --- | --- | --- |
85+
| a second build on one day, a dispatch or a cron | every tag of that day moves | `latest` and `<alias>` move |
86+
| a build with the same pacman version as yesterday | `<alias>-<anchor>` moves | `<alias>-<anchor>` stays on the first image with that version |
87+
| a port that published nothing earlier in the day | its `<alias>-v<date>` is created | the same |
88+
| the emergency path, TODO 4: a dispatch after the day published | moves every tag of the day | moves the rolling tags. The fix reaches `latest` and `<alias>`, and the dated tags keep the morning's image |
89+
90+
⚠ An anchor tag that exists now stays where it is, so each
91+
`<alias>-7.1.0.r9.g54d9411-2` keeps the image it pointed at when this change
92+
landed.
93+
94+
⚠ If the first image with a pacman version is defective, its anchor tag keeps
95+
it. The rolling and the next day's dated tags carry the fix.
96+
97+
## Measured
98+
99+
Dry run `38675434990` on a branch, into the scratch repository, the second
100+
build of 2026-10-15 there. The first was dry run `38670811239`. Digests read
101+
anonymously before and after, first 12 hex digits:
102+
103+
| tag in `ghcr.io/pkgforge-dev/archlinux-ci` | before | after |
104+
| --- | --- | --- |
105+
| `latest` | `445762b79215` | `6ee338a7249d` |
106+
| `v2026.10.15` | `445762b79215` | `445762b79215` |
107+
| `x86_64` | `316148d98390` | `dc77face842c` |
108+
| `x86_64-v2026.10.15` | `316148d98390` | `316148d98390` |
109+
| `x86_64-7.1.0.r9.g54d9411-2` | `316148d98390` | `316148d98390` |
110+
| `aarch64` | `ddf3f281cbab` | `e74f4e376390` |
111+
| `aarch64-v2026.10.15` | `ddf3f281cbab` | `ddf3f281cbab` |
112+
| `aarch64-7.1.0.r9.g54d9411-2` | `ddf3f281cbab` | `ddf3f281cbab` |
113+
| `riscv64` | `aa6be4315646` | `80b633486e7b` |
114+
| `riscv64-v2026.10.15` | `aa6be4315646` | `aa6be4315646` |
115+
| `riscv64-7.1.0.r9.g54d9411-2.1` | `aa6be4315646` | `aa6be4315646` |
116+
117+
The rolling tags moved and the pinned ones did not. The tag steps named 21
118+
pinned tags as kept, which is every pinned tag of the five ports that built,
119+
four for amd64, arm64 and loong64, six for armv7, two for riscv64, and
120+
`v2026.10.15`. The verify step read the platforms of `latest`:
121+
`published ghcr.io/pkgforge-dev/archlinux-ci:latest with platforms amd64 arm/v7 arm64 loong64 ppc ppc64 ppc64le riscv64, and ghcr.io/pkgforge-dev/archlinux-ci:v2026.10.15 exists`.
122+
123+
```bash
124+
gh run view 38675434990 --log | grep -E '^Create tags' | grep -c 'is pinned and exists'
125+
```
126+
127+
## What this does not verify
128+
129+
- ⚠ Docker Hub. The list is read from GHCR only. A pinned tag that exists on
130+
GHCR and not on Docker Hub, because an earlier copy failed, is not created on
131+
Docker Hub again. That earlier run's verify step fails loudly on the missing
132+
copy.
133+
- ⚠ Two aliases of one architecture can diverge if one pinned name exists and
134+
the other does not. Both are created by one command, so that needs an earlier
135+
partial failure.

0 commit comments

Comments
 (0)