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
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
branches: [main]
workflow_dispatch:
workflow_call:
inputs:
full_validation:
description: Build every board before publishing a new version
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -37,7 +42,9 @@ jobs:
- name: Install validation tools
run: python -m pip install PyYAML==6.0.3 idf-component-manager==2.5.0
- name: Test CI helpers
run: python -m unittest discover -s ci/scripts -p 'test_*.py' -v
run: |
python -m unittest discover -s ci/scripts -p 'test_*.py' -v
node --test ci/scripts/test_github_release.cjs
- name: Check board definitions and documentation navigation
run: |
python ci/scripts/board_pack.py check
Expand All @@ -48,10 +55,13 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
FULL_VALIDATION: ${{ inputs.full_validation || false }}
shell: bash
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "pull_request" || "$EVENT_NAME" == "push" ]]; then
if [[ "$FULL_VALIDATION" == true ]]; then
python ci/scripts/board_pack.py matrix --all
elif [[ "$EVENT_NAME" == "pull_request" || "$EVENT_NAME" == "push" ]]; then
python ci/scripts/board_pack.py matrix --base "$BASE_SHA"
else
python ci/scripts/board_pack.py matrix --all
Expand Down
88 changes: 79 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Publish board pack

on:
push:
branches: [main]
workflow_dispatch:
inputs:
dry_run:
Expand All @@ -16,14 +18,50 @@ concurrency:
group: board-pack-publish
cancel-in-progress: false

env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}

jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
publish: ${{ steps.plan.outputs.result }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install PyYAML==6.0.3
- id: version
name: Check release source and manifest
run: python ci/scripts/check_release.py
- id: plan
name: Skip versions with a completed release
uses: actions/github-script@v7
env:
RELEASE_TAG: ${{ steps.version.outputs.tag }}
with:
script: |
const release = require('./ci/scripts/github_release.cjs');
return release.shouldPublish({github, context, core}, process.env.RELEASE_TAG, process.env.DRY_RUN === 'true');

validate:
needs: prepare
if: needs.prepare.outputs.publish == 'true'
uses: ./.github/workflows/ci.yml
with:
full_validation: true

upload:
needs: validate
needs: [prepare, validate]
runs-on: ubuntu-latest
environment: component-registry
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -33,24 +71,56 @@ jobs:
python-version: '3.12'
- name: Install component tooling
run: python -m pip install PyYAML==6.0.3 idf-component-manager==2.5.0
- name: Check release reference
- name: Check release source and credentials
env:
DRY_RUN: ${{ inputs.dry_run }}
IDF_COMPONENT_API_TOKEN: ${{ secrets.IDF_COMPONENT_API_TOKEN }}
run: |
python ci/scripts/check_release.py
test -n "$IDF_COMPONENT_API_TOKEN" || { echo 'Set IDF_COMPONENT_API_TOKEN in Actions secrets.'; exit 1; }
- name: Create or verify immutable version tag
if: env.DRY_RUN == 'false'
uses: actions/github-script@v7
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
with:
script: |
const release = require('./ci/scripts/github_release.cjs');
await release.ensureTag({github, context}, process.env.RELEASE_TAG);
- name: Upload to waveshare namespace
env:
IDF_COMPONENT_API_TOKEN: ${{ secrets.IDF_COMPONENT_API_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
shell: bash
run: |
set -euo pipefail
if [[ -z "$IDF_COMPONENT_API_TOKEN" ]]; then
echo 'Set IDF_COMPONENT_API_TOKEN in the component-registry environment or repository Actions secrets.'
exit 1
fi
options=()
if [[ "$DRY_RUN" == true ]]; then
options+=(--dry-run)
fi
compote component upload --namespace waveshare --name waveshare_boards "${options[@]}"
compote component upload --namespace waveshare --name waveshare_boards --commit-sha "$GITHUB_SHA" "${options[@]}"

release:
needs: [prepare, upload]
if: ${{ success() && (github.event_name == 'push' || !inputs.dry_run) }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Publish GitHub Release after registry upload
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
shell: bash
run: |
set -euo pipefail
cat > release-notes.md <<EOF
Install: \`idf.py add-dependency "waveshare/waveshare_boards==$VERSION"\`

[ESP Component Registry](https://components.espressif.com/components/waveshare/waveshare_boards/versions/$VERSION/readme)
· [English documentation](https://github.com/$GH_REPO/blob/$RELEASE_TAG/README.md)
· [简体中文文档](https://github.com/$GH_REPO/blob/$RELEASE_TAG/README_ZH.md)

The complete board build matrix passed before this upload. Hardware behavior still requires testing on the physical board.
EOF
gh release create "$RELEASE_TAG" --verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md --generate-notes
19 changes: 13 additions & 6 deletions ci/scripts/check_release.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Require a version-matched tag on main before a real registry upload."""
"""Validate the manifest and restrict real releases to main or its version tag."""

import os
from pathlib import Path
import re
import subprocess
import sys

Expand All @@ -14,18 +15,24 @@
def check(root, dry_run, ref):
if dry_run not in {"true", "false"}:
raise ValueError("DRY_RUN must be true or false")
if dry_run == "true":
return
version = yaml.safe_load((root / "idf_component.yml").read_text())["version"]
if ref != f"refs/tags/v{version}":
raise ValueError("publish from the v<manifest-version> tag after merging to main")
if not isinstance(version, str) or not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version):
raise ValueError("release version must be a stable major.minor.patch string")
if dry_run == "true":
return version
if ref not in {"refs/heads/main", f"refs/tags/v{version}"}:
raise ValueError("publish from main or its v<manifest-version> tag")
subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"],
cwd=root, check=True)
return version


if __name__ == "__main__":
try:
check(ROOT, os.environ.get("DRY_RUN"), os.environ.get("GITHUB_REF"))
version = check(ROOT, os.environ.get("DRY_RUN"), os.environ.get("GITHUB_REF"))
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"version={version}\ntag=v{version}\n")
except Exception as error:
print(f"release check failed: {error}", file=sys.stderr)
sys.exit(1)
34 changes: 34 additions & 0 deletions ci/scripts/github_release.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// GitHub release operations are separate from registry credentials and uploads.
async function shouldPublish({github, context, core}, tag, dryRun) {
if (dryRun) return true;
try {
const {data} = await github.rest.repos.getReleaseByTag({...context.repo, tag});
if (data.draft) throw new Error(`Finish or remove the existing draft release ${tag} before publishing`);
core.info(`${tag} already has a completed release; skipping publication`);
return false;
} catch (error) {
if (error.status === 404) return true;
throw error;
}
}

async function ensureTag({github, context}, tag) {
let target;
try {
const {data} = await github.rest.git.getRef({...context.repo, ref: `tags/${tag}`});
target = data.object;
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.git.createRef({...context.repo, ref: `refs/tags/${tag}`, sha: context.sha});
return;
}
while (target.type === 'tag') {
const {data} = await github.rest.git.getTag({...context.repo, tag_sha: target.sha});
target = data.object;
}
if (target.type !== 'commit' || target.sha !== context.sha) {
throw new Error(`${tag} points to another commit; retry the tagged run or use a new version`);
}
}

module.exports = {shouldPublish, ensureTag};
39 changes: 39 additions & 0 deletions ci/scripts/test_github_release.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const {shouldPublish, ensureTag} = require('./github_release.cjs');
const context = {repo: {owner: 'vendor', repo: 'boards'}, sha: 'tested-commit'};
const missing = () => { throw Object.assign(new Error('Missing'), {status: 404}); };
const unavailable = () => { throw Object.assign(new Error('Unavailable'), {status: 503}); };
function fixture(repos = {}, git = {}) {
return {context, github: {rest: {repos, git}}, core: {info() {}}};
}

test('dry run does not query or mutate releases', async () => {
assert.equal(await shouldPublish(fixture(), 'v0.1.0', true), true);
});
test('new version proceeds, completed version skips', async () => {
assert.equal(await shouldPublish(fixture({getReleaseByTag: missing}), 'v0.1.0', false), true);
assert.equal(await shouldPublish(fixture({getReleaseByTag: async () => ({data: {draft: false}})}), 'v0.1.0', false), false);
});
test('draft and unavailable release status do not authorize publishing', async () => {
await assert.rejects(shouldPublish(fixture({getReleaseByTag: async () => ({data: {draft: true}})}), 'v0.1.0', false), /draft/);
await assert.rejects(shouldPublish(fixture({getReleaseByTag: unavailable}), 'v0.1.0', false), /Unavailable/);
});
test('new tag points only to the tested workflow commit', async () => {
const calls = [];
await ensureTag(fixture({}, {getRef: missing, createRef: async args => calls.push(args)}), 'v0.1.0');
assert.deepEqual(calls, [{...context.repo, ref: 'refs/tags/v0.1.0', sha: context.sha}]);
});
test('matching lightweight and annotated tags are reusable without mutation', async () => {
await ensureTag(fixture({}, {getRef: async () => ({data: {object: {type: 'commit', sha: context.sha}}})}), 'v0.1.0');
await ensureTag(fixture({}, {
getRef: async () => ({data: {object: {type: 'tag', sha: 'annotated'}}}),
getTag: async () => ({data: {object: {type: 'commit', sha: context.sha}}})
}), 'v0.1.0');
});
test('different source cannot move an existing version tag', async () => {
await assert.rejects(ensureTag(fixture({}, {getRef: async () => ({data: {object: {type: 'commit', sha: 'other'}}})}), 'v0.1.0'), /another commit/);
});
test('tag lookup network failure cannot be treated as missing', async () => {
await assert.rejects(ensureTag(fixture({}, {getRef: unavailable}), 'v0.1.0'), /Unavailable/);
});
35 changes: 31 additions & 4 deletions ci/scripts/test_repository_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,37 @@ def test_catalog_replaces_stale_boards_and_check_never_writes(self):
with self.assertRaises(ValueError):
catalog.update(self.root)

def test_real_publish_requires_matching_tag_and_main_ancestry(self):
def test_real_publish_requires_main_or_matching_tag_and_main_ancestry(self):
(self.root / 'idf_component.yml').write_text('version: "0.1.0"\n')
check_release.check(self.root, 'true', 'refs/heads/feature')
for ref in ('refs/heads/main', 'refs/tags/v0.2.0'):
for ref in ('refs/heads/feature', 'refs/tags/v0.2.0'):
with self.assertRaises(ValueError):
check_release.check(self.root, 'false', ref)
with patch('check_release.subprocess.run') as run:
self.assertEqual('0.1.0', check_release.check(self.root, 'false', 'refs/heads/main'))
check_release.check(self.root, 'false', 'refs/tags/v0.1.0')
self.assertEqual(['git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'], run.call_args.args[0])
run.side_effect = subprocess.CalledProcessError(1, 'git')
with self.assertRaises(subprocess.CalledProcessError):
check_release.check(self.root, 'false', 'refs/tags/v0.1.0')

def test_release_rejects_invalid_versions_and_modes(self):
for version in ('01.0.0', '0.1.0-rc1', 'bad;command'):
(self.root / 'idf_component.yml').write_text(f'version: {version!r}\n')
with self.assertRaises(ValueError):
check_release.check(self.root, 'true', 'refs/heads/main')
with self.assertRaises(ValueError):
check_release.check(self.root, 'yes', 'refs/heads/main')

def test_release_cli_emits_manifest_version(self):
output = self.root / 'outputs'
result = subprocess.run([sys.executable, 'ci/scripts/check_release.py'], cwd=ROOT,
env={**os.environ, 'DRY_RUN': 'true', 'GITHUB_OUTPUT': str(output)},
capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
version = yaml.safe_load((ROOT / 'idf_component.yml').read_text())['version']
self.assertEqual(f'version={version}\ntag=v{version}\n', output.read_text())

def test_exact_lightweight_workflow_commands(self):
for command in [['ci/scripts/board_pack.py', 'check'],
['scripts/update_supported_boards_table.py', '--check'],
Expand All @@ -88,10 +106,19 @@ def test_workflow_consumes_classifier_and_separates_credentials(self):
self.assertIn('always()', jobs['result']['if'])
self.assertNotIn('secrets.', (ROOT / '.github/workflows/ci.yml').read_text())
publish = yaml.load((ROOT / '.github/workflows/publish.yml').read_text(), Loader=yaml.BaseLoader)
self.assertEqual(['workflow_dispatch'], list(publish['on']))
self.assertEqual(['main'], publish['on']['push']['branches'])
self.assertEqual('true', publish['on']['workflow_dispatch']['inputs']['dry_run']['default'])
self.assertEqual('component-registry', publish['jobs']['upload']['environment'])
self.assertEqual('validate', publish['jobs']['upload']['needs'])
self.assertEqual(['prepare', 'validate'], publish['jobs']['upload']['needs'])
self.assertEqual('true', publish['jobs']['validate']['with']['full_validation'])
self.assertIn('needs.prepare.outputs.publish', publish['jobs']['validate']['if'])
self.assertEqual(['prepare', 'upload'], publish['jobs']['release']['needs'])
self.assertIn('!inputs.dry_run', publish['jobs']['release']['if'])
self.assertEqual('read', publish['permissions']['contents'])
self.assertEqual('write', publish['jobs']['upload']['permissions']['contents'])
scope = next(step for step in jobs['validate']['steps'] if step.get('id') == 'scope')
self.assertIn('FULL_VALIDATION', scope['env'])
self.assertIn('matrix --all', scope['run'])


if __name__ == '__main__':
Expand Down
41 changes: 30 additions & 11 deletions docs/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ The GitHub repository is `waveshareteam/waveshare_boards`. The registry identity
Keep the `esp_board_manager`, `board_manager`, and `boards` tags in the manifest
so Board Manager can discover the pack.

No registry version is created by a pull request, a merge, or normal CI.
Pull requests run CI without publishing. A push or merge to `main` automatically
starts the release workflow. A completed GitHub Release for the manifest version
makes the publication jobs skip; publishing a new version runs the full build matrix.
`compote component pack --name waveshare_boards` validates the package locally without
a token. Generated archives remain under ignored `dist/`.

Expand Down Expand Up @@ -46,13 +48,30 @@ it does not affect ordinary PR CI.
## Publish a version

1. Update `version` in `idf_component.yml`, keep both language pages current,
and merge the PR after all required checks pass.
2. Create a `v<version>` tag on that reviewed commit, for example `v0.1.0`.
3. Run **Publish board pack** from that tag and clear **dry_run**. The workflow
checks that the tag matches the manifest and that the commit belongs to `main`,
reruns all board builds, then uploads `waveshare/waveshare_boards`.
4. Verify the registry version page and install it into a fresh application.

Published versions are immutable. A duplicate version fails rather than silently
accepting different source. Make a new version for subsequent changes. See the
[Compote upload reference](https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/compote_cli.html#upload).
and merge the PR after all required checks pass. The initial version is `0.1.0`.
2. **Publish board pack** runs automatically on `main`. For a new version, it
compiles every board, creates `v<version>` on that exact commit, uploads
`waveshare/waveshare_boards`, and creates the GitHub Release after the registry
confirms success. Release validation always builds the full matrix, including
when the ordinary CI change classifier selects fewer boards.
3. Verify the registry version page and install it into a fresh application:

```bash
idf.py add-dependency "waveshare/waveshare_boards==0.1.0"
```

Merging with an already released version does not publish again. Increase the
manifest version when subsequent changes should reach registry users. Published
versions and existing tags are never overwritten or moved. An existing tag for
another commit stops the upload instead of publishing different source under it.

For an upload failure, fix credentials or permissions and rerun the failed jobs
on the same workflow commit. If a tag was already created, a manual real upload
can also run from that tag with **dry_run** cleared. Manual real uploads accept
`main` or the matching version tag, and require the source to belong to `main`.
If the upload succeeded but GitHub Release creation failed, rerun only the failed
`release` job; the successful upload job is kept. Do not re-upload an immutable
registry version. Release runs are serialized and are not cancelled by later merges.

The [Compote upload reference](https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/compote_cli.html#upload)
describes registry validation and duplicate-version behavior.
Loading
Loading