Skip to content

Commit 0b38e4e

Browse files
committed
ci: gate VS Code tags on exact-SHA release preflight
1 parent 951cb05 commit 0b38e4e

6 files changed

Lines changed: 231 additions & 20 deletions

File tree

‎.github/workflows/vscode-extension-release.yml‎

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,156 @@
11
name: Release VS Code extension
22

33
on:
4-
push:
5-
tags: ["vscode-v*"]
4+
workflow_dispatch:
5+
inputs:
6+
candidate_sha:
7+
description: Exact 40-character branch-head commit SHA to release
8+
required: true
9+
type: string
10+
dry_run:
11+
description: Validate full CI and VSIX artifacts without tagging or publishing
12+
required: false
13+
default: false
14+
type: boolean
615

7-
# Never cancelled and never coalesced: every publish runs to completion.
816
concurrency:
917
group: ${{ github.workflow }}-${{ github.run_id }}
1018
cancel-in-progress: false
1119

20+
permissions:
21+
contents: read
22+
actions: read
23+
1224
jobs:
1325
full-coverage:
1426
runs-on: ubuntu-24.04
15-
permissions:
16-
actions: read
17-
contents: read
1827
steps:
1928
- uses: actions/checkout@v4
20-
- name: Require exact-SHA full release CI before publication
29+
with:
30+
ref: ${{ inputs.candidate_sha }}
31+
- name: Require exact-SHA full release CI before tagging
2132
env:
2233
GH_TOKEN: ${{ github.token }}
23-
run: python ci/verify_full_coverage.py --sha "$GITHUB_SHA"
34+
EXPECTED_SHA: ${{ inputs.candidate_sha }}
35+
run: |
36+
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
37+
[[ "$GITHUB_REF" == refs/heads/* ]]
38+
test "$EXPECTED_SHA" = "$GITHUB_SHA"
39+
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
40+
python ci/verify_full_coverage.py --sha "$EXPECTED_SHA"
2441
25-
validate-tag:
42+
validate-version:
43+
needs: [full-coverage]
2644
runs-on: ubuntu-24.04
2745
outputs:
2846
version: ${{ steps.version.outputs.version }}
47+
tag: ${{ steps.version.outputs.tag }}
2948
steps:
3049
- uses: actions/checkout@v4
50+
with:
51+
ref: ${{ inputs.candidate_sha }}
3152
- id: version
53+
env:
54+
EXPECTED_SHA: ${{ inputs.candidate_sha }}
3255
run: |
56+
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
3357
VERSION=$(node -p "require('./vscode-plugin/package.json').version")
34-
test "${GITHUB_REF_NAME}" = "vscode-v${VERSION}"
58+
TAG="vscode-v${VERSION}"
59+
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
60+
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
61+
test "$(git rev-parse "${TAG}^{commit}")" = "$EXPECTED_SHA" || {
62+
echo "Existing tag ${TAG} points to another commit" >&2
63+
exit 1
64+
}
65+
fi
3566
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
67+
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
68+
3669
build:
37-
needs: [validate-tag, full-coverage]
38-
if: needs.full-coverage.result == 'success'
70+
needs: [full-coverage, validate-version]
71+
if: needs.full-coverage.result == 'success' && needs.validate-version.result == 'success'
3972
uses: ./.github/workflows/vscode-extension.yml
4073
with:
4174
release_build: true
75+
76+
artifact-preflight:
77+
needs: [full-coverage, validate-version, build]
78+
if: needs.full-coverage.result == 'success' && needs.validate-version.result == 'success' && needs.build.result == 'success'
79+
runs-on: ubuntu-24.04
80+
steps:
81+
- uses: actions/checkout@v4
82+
with:
83+
ref: ${{ inputs.candidate_sha }}
84+
- uses: actions/download-artifact@v4
85+
with: { pattern: vscode-*, path: artifacts, merge-multiple: true }
86+
- name: Validate the seven VSIX packages before tagging
87+
env:
88+
VERSION: ${{ needs.validate-version.outputs.version }}
89+
EXPECTED_SHA: ${{ inputs.candidate_sha }}
90+
run: |
91+
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
92+
python ci/vscode_release_artifact_lint.py --artifacts artifacts --version "$VERSION"
93+
94+
create-tag:
95+
needs: [full-coverage, validate-version, artifact-preflight]
96+
if: >-
97+
!cancelled() &&
98+
inputs.dry_run != true &&
99+
needs.full-coverage.result == 'success' &&
100+
needs.validate-version.result == 'success' &&
101+
needs.artifact-preflight.result == 'success'
102+
runs-on: ubuntu-24.04
103+
permissions:
104+
contents: write
105+
steps:
106+
- uses: actions/checkout@v4
107+
with:
108+
ref: ${{ inputs.candidate_sha }}
109+
- name: Create release tag from the verified commit
110+
env:
111+
EXPECTED_SHA: ${{ inputs.candidate_sha }}
112+
TAG: ${{ needs.validate-version.outputs.tag }}
113+
run: |
114+
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
115+
git config --local user.email "action@github.com"
116+
git config --local user.name "GitHub Action"
117+
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
118+
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
119+
test "$(git rev-parse "${TAG}^{commit}")" = "$EXPECTED_SHA" || {
120+
echo "Existing tag ${TAG} points to another commit" >&2
121+
exit 1
122+
}
123+
else
124+
git tag -a "$TAG" -m "Release VS Code ${{ needs.validate-version.outputs.version }}"
125+
git push origin "refs/tags/${TAG}"
126+
fi
127+
42128
publish:
43-
needs: [validate-tag, full-coverage, build]
44-
if: needs.full-coverage.result == 'success' && needs.build.result == 'success'
129+
needs: [full-coverage, validate-version, artifact-preflight, create-tag]
130+
if: >-
131+
!cancelled() &&
132+
inputs.dry_run != true &&
133+
needs.full-coverage.result == 'success' &&
134+
needs.artifact-preflight.result == 'success' &&
135+
needs.create-tag.result == 'success'
45136
runs-on: ubuntu-24.04
46137
permissions:
47138
contents: write
48139
id-token: write
49140
attestations: write
50141
steps:
142+
- uses: actions/checkout@v4
143+
with:
144+
ref: ${{ inputs.candidate_sha }}
51145
- uses: actions/download-artifact@v4
52-
with: { path: artifacts, merge-multiple: true }
146+
with: { pattern: vscode-*, path: artifacts, merge-multiple: true }
147+
- name: Verify release artifacts again
148+
env:
149+
VERSION: ${{ needs.validate-version.outputs.version }}
150+
EXPECTED_SHA: ${{ inputs.candidate_sha }}
151+
run: |
152+
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
153+
python ci/vscode_release_artifact_lint.py --artifacts artifacts --version "$VERSION"
53154
- name: Produce checksums
54155
run: |
55156
cd artifacts
@@ -60,7 +161,9 @@ jobs:
60161
- name: Create GitHub release from the verified package bytes
61162
env:
62163
GH_TOKEN: ${{ github.token }}
63-
run: gh release create "$GITHUB_REF_NAME" artifacts/* --title "FastLED VS Code ${{ needs.validate-tag.outputs.version }}" --generate-notes
164+
TAG: ${{ needs.validate-version.outputs.tag }}
165+
VERSION: ${{ needs.validate-version.outputs.version }}
166+
run: gh release create "$TAG" artifacts/* --title "FastLED VS Code $VERSION" --generate-notes
64167
- name: Publish those same prebuilt VSIX files to Marketplace
65168
env:
66169
VSCE_PAT: ${{ secrets.VSCE_PAT }}

‎.github/workflows/vscode-extension.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
workflow_call:
55
inputs:
66
release_build:
7-
description: Build VSIX artifacts for the tag-triggered release caller
7+
description: Build VSIX artifacts for the exact-SHA release caller
88
required: false
99
type: boolean
1010
default: false

‎CLAUDE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ If `soldr` is not on PATH, install it with `uv tool install soldr`.
2121
Ordinary PR/main CI is minimal. The literal `ci-test` PR label adds Linux x86
2222
unit and integration tests; `ci-full` runs the platform matrix. Full validation
2323
must pass on the exact release SHA before tagging or publishing. A version bump
24-
on `main` no longer starts a release. See [fractional CI and release gates](docs/FRACTIONAL_CI.md).
24+
on `main` or a pushed VS Code tag no longer starts a release. Both CLI and
25+
VS Code releases require manual exact-SHA dispatch and artifact preflight.
26+
See [fractional CI and release gates](docs/FRACTIONAL_CI.md).
2527

2628
`bash test` runs the Python API smoke tests plus the Rust workspace tests. End-to-end WASM compiles should be run only when the change touches the native build backend.
2729

‎ci/vscode_release_artifact_lint.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Validate the complete VS Code release payload before creating its tag."""
2+
3+
import argparse
4+
import json
5+
import zipfile
6+
from pathlib import Path
7+
8+
TARGETS = frozenset(
9+
{
10+
"win32-x64",
11+
"win32-arm64",
12+
"linux-x64",
13+
"linux-arm64",
14+
"darwin-x64",
15+
"darwin-arm64",
16+
"universal",
17+
}
18+
)
19+
20+
21+
def check(artifacts: Path, version: str) -> list[Path]:
22+
expected = {f"fastled-wasm-{version}-{target}.vsix" for target in TARGETS}
23+
found = {path.name for path in artifacts.iterdir() if path.is_file()}
24+
if found != expected:
25+
raise ValueError(
26+
f"expected exact seven versioned VSIX files: {sorted(expected)}; got {sorted(found)}"
27+
)
28+
packages = sorted(artifacts / name for name in expected)
29+
for package in packages:
30+
if package.stat().st_size == 0:
31+
raise ValueError(f"empty VSIX: {package.name}")
32+
try:
33+
with zipfile.ZipFile(package) as archive:
34+
if archive.testzip() is not None:
35+
raise ValueError(f"corrupt VSIX: {package.name}")
36+
manifest = json.loads(archive.read("extension/package.json"))
37+
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as error:
38+
raise ValueError(f"invalid VSIX: {package.name}: {error}") from error
39+
if manifest.get("name") != "fastled-wasm" or manifest.get("version") != version:
40+
raise ValueError(f"VSIX manifest mismatch: {package.name}")
41+
return packages
42+
43+
44+
def main() -> None:
45+
parser = argparse.ArgumentParser()
46+
parser.add_argument("--artifacts", type=Path, required=True)
47+
parser.add_argument("--version", required=True)
48+
args = parser.parse_args()
49+
packages = check(args.artifacts, args.version)
50+
print(f"Validated {len(packages)} VSIX packages for version {args.version}")
51+
52+
53+
if __name__ == "__main__":
54+
main()

‎docs/FRACTIONAL_CI.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ candidate_sha=<40-hex-sha> -f dry_run=true`. The branch tip must be that SHA.
2929
The preflight rejects missing/corrupt wheels, missing binaries, and wheels at
3030
or above 100 MB; it cannot reserve a PyPI upload or verify the project's
3131
remaining storage quota. A conflicting tag is rejected before the six builds.
32-
The VS Code tag-push publication workflow checks the same report before
33-
building or publishing.
32+
The VS Code extension release is also manual-only. Dispatch
33+
`vscode-extension-release.yml` at the candidate branch with the same
34+
`candidate_sha`; `dry_run=true` exercises its seven-platform VSIX build and
35+
artifact preflight without creating `vscode-v<version>` or publishing. A real
36+
attempt checks the exact-SHA full-coverage report, builds and validates all
37+
seven version-matched VSIX files, and only then creates the VS Code tag and
38+
publishes. Pushing a `vscode-v*` tag does not start a release workflow.
3439

3540
The normalized issue-driven release front door and automated dispatch of the
3641
29 full workflows are still pending. A candidate must currently be the tip of

‎tests/unit/test_ci_modes.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,53 @@ def test_vscode_matrix_only_runs_for_full_pr_or_tag_release():
6666
assert "release_build: true" in release
6767

6868

69+
def test_vscode_release_cannot_create_tag_before_full_coverage_and_preflight():
70+
release = (WORKFLOWS / "vscode-extension-release.yml").read_text()
71+
assert " push:\n tags:" not in release
72+
assert " workflow_dispatch:" in release
73+
assert "candidate_sha:" in release
74+
assert "dry_run:" in release
75+
assert "verify_full_coverage.py" in release
76+
assert "vscode_release_artifact_lint.py" in release
77+
assert " create-tag:" in release
78+
tag = release.split(" create-tag:\n", 1)[1].split(" publish:\n", 1)[0]
79+
assert "needs.full-coverage.result == 'success'" in tag
80+
assert "needs.artifact-preflight.result == 'success'" in tag
81+
assert "inputs.dry_run != true" in tag
82+
assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in tag
83+
assert "git push origin" in tag
84+
publish = release.split(" publish:\n", 1)[1]
85+
assert "needs.create-tag.result == 'success'" in publish
86+
87+
88+
def test_vscode_release_artifact_lint_rejects_missing_corrupt_and_wrong_version(
89+
tmp_path,
90+
):
91+
spec = importlib.util.spec_from_file_location(
92+
"vscode_release_artifact_lint", ROOT / "ci" / "vscode_release_artifact_lint.py"
93+
)
94+
assert spec and spec.loader
95+
module = importlib.util.module_from_spec(spec)
96+
spec.loader.exec_module(module)
97+
targets = {
98+
"win32-x64", "win32-arm64", "linux-x64", "linux-arm64",
99+
"darwin-x64", "darwin-arm64", "universal",
100+
}
101+
for target in targets:
102+
with zipfile.ZipFile(
103+
tmp_path / f"fastled-wasm-1.0.1-{target}.vsix", "w"
104+
) as archive:
105+
archive.writestr(
106+
"extension/package.json", json.dumps({"name": "fastled-wasm", "version": "1.0.1"})
107+
)
108+
assert len(module.check(tmp_path, "1.0.1")) == 7
109+
with pytest.raises(ValueError):
110+
module.check(tmp_path, "1.0.2")
111+
(tmp_path / "fastled-wasm-1.0.1-universal.vsix").write_bytes(b"corrupt")
112+
with pytest.raises(ValueError):
113+
module.check(tmp_path, "1.0.1")
114+
115+
69116
def test_release_does_not_wait_for_removed_routine_build_artifacts():
70117
workflow = (WORKFLOWS / "auto-release.yml").read_text()
71118
assert "collect-artifacts:" not in workflow

0 commit comments

Comments
 (0)