mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили `timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180, больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь пишет в summary сдвиг старта и предупреждает, если он больше часа. test/workflow-hygiene.test.mjs держит все три правила по тексту workflow (разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг сдвига старта настоящим bash; порядок осознанного подъёма образа — docs/DEVELOPMENT.md. Issue: #658 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
340 lines
16 KiB
YAML
340 lines
16 KiB
YAML
name: Публикация пре-релиза (ручная)
|
|
run-name: Publish ${{ inputs.tag }}
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Exact prerelease tag, for example v1.61.0-beta.4"
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
actions: read
|
|
issues: read
|
|
|
|
concurrency:
|
|
group: publish-prerelease-${{ inputs.tag }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
gate:
|
|
name: "Гейт: зелёная Проверка и релизный контракт"
|
|
runs-on: ubuntu-24.04
|
|
# Больше ожидания зелёного Validate в release-gate.mjs (до 60 мин) (#658).
|
|
timeout-minutes: 75
|
|
outputs:
|
|
sha: ${{ steps.candidate.outputs.sha }}
|
|
tag: ${{ steps.candidate.outputs.tag }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
with: { node-version: 22 }
|
|
- name: Pin the dev candidate or the existing annotated tag
|
|
id: candidate
|
|
env:
|
|
TAG: ${{ inputs.tag }}
|
|
REF_NAME: ${{ github.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
test "$REF_NAME" = "dev" || {
|
|
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
|
exit 1
|
|
}
|
|
DISPATCHED_SHA=$(git rev-parse HEAD)
|
|
git fetch --force origin dev --tags
|
|
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
|
if [ -n "$REMOTE" ]; then
|
|
SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
|
test -n "$SHA" || {
|
|
echo "::error::Existing remote tag $TAG is not annotated"
|
|
exit 1
|
|
}
|
|
else
|
|
SHA=$DISPATCHED_SHA
|
|
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
|
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
|
exit 1
|
|
}
|
|
fi
|
|
git checkout --detach "$SHA"
|
|
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
- name: Verify version, changelogs and bilingual release notes
|
|
env:
|
|
TAG: ${{ inputs.tag }}
|
|
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
|
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
|
|
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
|
|
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
|
|
- name: Require the Release trailer on the candidate commit
|
|
env:
|
|
SHA: ${{ steps.candidate.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
|
|
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
|
|
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
|
exit 1
|
|
fi
|
|
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
|
|
# кандидате она обязана быть доказана строгим режимом.
|
|
- name: Documentation screenshots are fresh for the candidate
|
|
run: node scripts/check-docs.mjs --screenshots=strict
|
|
- name: Require green Validate for this exact SHA
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
SHA: ${{ steps.candidate.outputs.sha }}
|
|
run: node scripts/release-gate.mjs "$SHA"
|
|
- name: Bind issue membership to the exact candidate
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ steps.candidate.outputs.tag }}
|
|
SHA: ${{ steps.candidate.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p release-membership
|
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true
|
|
fi
|
|
if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then
|
|
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
|
--input=release-membership/RELEASE-MEMBERSHIP.json
|
|
else
|
|
ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
|
--label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")')
|
|
node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \
|
|
--issues="$ISSUES" --allow-unmatched \
|
|
--output=release-membership/RELEASE-MEMBERSHIP.json
|
|
fi
|
|
- name: Preserve candidate membership for publication
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: release-membership
|
|
path: release-membership/RELEASE-MEMBERSHIP.json
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
publish:
|
|
name: Публикация тега и релиза
|
|
needs: gate
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 20
|
|
outputs:
|
|
url: ${{ steps.verify.outputs.url }}
|
|
newly_published: ${{ steps.release.outputs.newly_published }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
ref: ${{ needs.gate.outputs.sha }}
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
with: { node-version: 22 }
|
|
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
name: release-membership
|
|
path: release-assets
|
|
- name: Build and verify both release assets before publication
|
|
env:
|
|
TAG: ${{ needs.gate.outputs.tag }}
|
|
SHA: ${{ needs.gate.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
npm ci
|
|
npm run build
|
|
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
|
npm run bundle:budget
|
|
VERSION=${TAG#v}
|
|
grep -RFq "$VERSION" dist
|
|
# #540: тот же способ, что у release.yml и release-prerelease.mjs —
|
|
# архив закоммиченного дерева точного коммита, детерминированный.
|
|
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
|
|
"$SHA:custom_components/houseplan"
|
|
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
|
custom_components/houseplan/frontend "$VERSION"
|
|
test -s dist/houseplan-card.js
|
|
test -s dist/houseplan-panel.js
|
|
test -s houseplan.zip
|
|
mkdir -p release-assets
|
|
cp dist/houseplan-card.js houseplan.zip release-assets/
|
|
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
|
--input=release-assets/RELEASE-MEMBERSHIP.json
|
|
node scripts/release-assets.mjs sums release-assets --include-membership
|
|
- name: Create or verify the annotated tag
|
|
env:
|
|
TAG: ${{ needs.gate.outputs.tag }}
|
|
SHA: ${{ needs.gate.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
|
if [ -n "$REMOTE" ]; then
|
|
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
|
test -n "$PEELED" || {
|
|
echo "::error::Existing remote tag $TAG is not annotated"
|
|
exit 1
|
|
}
|
|
test "$PEELED" = "$SHA" || {
|
|
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
|
exit 1
|
|
}
|
|
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
|
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
|
|
else
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git tag -a "$TAG" "$SHA" -m "$TAG"
|
|
git push origin "$TAG"
|
|
fi
|
|
- name: Stage, verify and publish the prerelease
|
|
id: release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.gate.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
|
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
|
fi
|
|
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
|
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
|
if [ "$WAS_DRAFT" = "false" ]; then
|
|
mkdir -p existing-public
|
|
if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \
|
|
--pattern houseplan-card.js --pattern houseplan.zip \
|
|
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \
|
|
&& diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \
|
|
&& node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \
|
|
&& node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \
|
|
--input=existing-public/RELEASE-MEMBERSHIP.json; then
|
|
echo "release is already public and byte-identical; publication skipped"
|
|
exit 0
|
|
fi
|
|
echo "existing public assets need recovery; verified files will be uploaded again"
|
|
fi
|
|
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
|
|
release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \
|
|
--repo "$GITHUB_REPOSITORY" --clobber
|
|
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--json tagName,isDraft,isPrerelease,assets,url)
|
|
export RELEASE_JSON TAG
|
|
node <<'NODE'
|
|
const release = JSON.parse(process.env.RELEASE_JSON);
|
|
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
|
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
|
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
|
|
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
|
}
|
|
NODE
|
|
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
|
|
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
|
- name: Verify the public release and assets
|
|
id: verify
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.gate.outputs.tag }}
|
|
SHA: ${{ needs.gate.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--json tagName,isDraft,isPrerelease,assets,url)
|
|
export RELEASE_JSON TAG
|
|
node <<'NODE'
|
|
const release = JSON.parse(process.env.RELEASE_JSON);
|
|
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
|
throw new Error('release is not a public prerelease for the requested tag');
|
|
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
|
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
|
|
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
|
}
|
|
NODE
|
|
# #540: публичные байты — ровно те, что собраны и проверены выше.
|
|
mkdir -p public
|
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
|
|
--pattern houseplan-card.js --pattern houseplan.zip \
|
|
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber
|
|
diff -u release-assets/SHA256SUMS public/SHA256SUMS
|
|
node scripts/release-assets.mjs check public release-assets/SHA256SUMS
|
|
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
|
--input=public/RELEASE-MEMBERSHIP.json
|
|
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
|
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
|
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \
|
|
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
|
|
- name: Verify HACS prerelease discovery order
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
env:
|
|
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
|
with:
|
|
script: |
|
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
per_page: 100,
|
|
});
|
|
const first = releases.find((release) => release.prerelease && !release.draft);
|
|
if (first?.tag_name !== process.env.EXPECTED_TAG) {
|
|
core.setFailed(
|
|
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
|
|
process.env.EXPECTED_TAG,
|
|
);
|
|
}
|
|
|
|
# #547: bookkeeping is driven by the immutable candidate manifest, not by the
|
|
# mutable S8 queue. It also runs on a verified retry of an already-public beta.
|
|
close-merged:
|
|
name: Закрытие вошедших issue
|
|
needs: [gate, publish]
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
|
|
# not start workflows, so removing the label cannot wake the review
|
|
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
|
|
issues: write
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
ref: ${{ needs.gate.outputs.sha }}
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
with: { node-version: 22 }
|
|
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
name: release-membership
|
|
path: release-membership
|
|
- name: Finish only the issues proven in the candidate manifest
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
TAG: ${{ needs.gate.outputs.tag }}
|
|
URL: ${{ needs.publish.outputs.url }}
|
|
run: |
|
|
set -euo pipefail
|
|
node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \
|
|
--candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \
|
|
--membership=release-membership/RELEASE-MEMBERSHIP.json
|
|
|
|
announce:
|
|
name: Комментарий о публикации
|
|
needs: [gate, publish]
|
|
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
|
uses: ./.github/workflows/announce.yml
|
|
with:
|
|
reusable: true
|
|
tag: ${{ needs.gate.outputs.tag }}
|
|
release_name: ${{ needs.gate.outputs.tag }}
|
|
url: ${{ needs.publish.outputs.url }}
|
|
prerelease: true
|
|
ref: ${{ needs.gate.outputs.tag }}
|
|
secrets: inherit
|