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
123 changes: 97 additions & 26 deletions .github/workflows/check-plcc-ng-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,43 +44,114 @@ jobs:

BRANCH="chore/update-plcc-ng-${LATEST}"

# Idempotency guard: PR branch already exists
if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
echo "Branch '$BRANCH' already exists. PR already open."
exit 0
# The image's major tag is the stability contract for courses
# (docs/choosing-your-image.md tells them to pin it for a term), so a
# new plcc-ng major has to release the image as a major too. Otherwise
# a patch release would move :N onto a new major of the tool.
# Deliberately `feat:` and not `feat!:` — the Angular preset that
# semantic-release uses does not parse the `!` shorthand, so a
# `feat!:` subject whose BREAKING CHANGE footer went missing yields
# no release at all. `feat:` degrades to a minor instead.
if [ "${LATEST%%.*}" != "${CURRENT%%.*}" ]; then
COMMIT_SUBJECT="feat: update plcc-ng to ${LATEST}"
COMMIT_BODY="BREAKING CHANGE: plcc-ng ${LATEST} is a new major version (was ${CURRENT}). Images already published under the previous major tag keep plcc-ng ${CURRENT}; this release publishes under a new major tag."
else
COMMIT_SUBJECT="fix: update plcc-ng to ${LATEST}"
COMMIT_BODY=""
fi
echo "Release type: $COMMIT_SUBJECT"

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
# Idempotency guard: keyed on the PR, not the branch. A branch with no
# PR means an earlier run died between push and PR creation; that must
# be recoverable, not mistaken for work already done.
if [ -n "$(gh pr list --head "$BRANCH" --state all --json number --jq '.[].number')" ]; then
echo "A PR for '$BRANCH' already exists. No action needed."
exit 0
fi

for cfg in images/plcc-ng/.devcontainer/devcontainer.json \
images/plcc-ng-full/.devcontainer/devcontainer.json; do
if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
echo "Branch '$BRANCH' exists with no PR. Recovering by opening the PR."
# actions/checkout configures a single-branch refspec, so no
# origin/$BRANCH tracking ref exists here — use FETCH_HEAD.
git fetch --depth=1 origin "$BRANCH"
git checkout -B "$BRANCH" FETCH_HEAD
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"

# Pinned version in both prebuilt images.
for cfg in images/plcc-ng/.devcontainer/devcontainer.json \
images/plcc-ng-full/.devcontainer/devcontainer.json; do
jq --arg v "$LATEST" \
'.features["./features/plcc-ng"].version = $v' \
"$cfg" > tmp.json
mv tmp.json "$cfg"
done

# Feature test: the scenario pin and the assertion that checks it
# must move together, or the test fails against its own scenario.
jq --arg v "$LATEST" \
'.features["./features/plcc-ng"].version = $v' \
"$cfg" > tmp.json
mv tmp.json "$cfg"
done

git add images
git commit -m "fix: update plcc-ng to ${LATEST}"

# Race-condition guard: treat push failure as no-op
git push origin "$BRANCH" || {
echo "Push failed — branch may already exist. Exiting cleanly."
exit 0
}
'.pinned_version.features["plcc-ng"].version = $v' \
test/plcc-ng/scenarios.json > tmp.json
mv tmp.json test/plcc-ng/scenarios.json
sed -i "s/^EXPECTED_VERSION=.*/EXPECTED_VERSION=\"${LATEST}\"/" \
test/plcc-ng/pinned_version.sh

# Feature metadata names a concrete version in its proposals and
# description. Bump the feature's own patch version so the edit is
# actually published — publish skips already-published versions.
# The description contains single quotes, so build it in the shell
# and pass it as an argument rather than embedding it in the
# single-quoted jq program.
DESC="PyPI version of plcc-ng to install (e.g. '${LATEST}'), or 'latest' for the newest release."
jq --arg v "$LATEST" --arg desc "$DESC" \
'.options.version.proposals = ["latest", $v]
| .options.version.description = $desc
| .version = ((.version | split(".")) as $p
| "\($p[0]).\($p[1]).\($p[2] | tonumber + 1)")' \
src/plcc-ng/devcontainer-feature.json > tmp.json
mv tmp.json src/plcc-ng/devcontainer-feature.json

# Version example in the docs. Anchored on the backticks so it
# cannot also rewrite the Java feature's { "version": "21" }.
sed -i "s|\`{ \"version\": \"[0-9][^\"]*\" }\`|\`{ \"version\": \"${LATEST}\" }\`|" \
docs/choosing-your-image.md

git add images test src docs
if [ -n "$COMMIT_BODY" ]; then
git commit -m "$COMMIT_SUBJECT" -m "$COMMIT_BODY"
else
git commit -m "$COMMIT_SUBJECT"
fi

# Race-condition guard: treat push failure as no-op
git push origin "$BRANCH" || {
echo "Push failed — concurrent run. Exiting cleanly."
exit 0
}
fi

gh pr create \
--title "fix: update plcc-ng to ${LATEST}" \
--label "automated" \
# The PR title becomes the squash-merge commit subject, so it carries
# the release type. The body carries the BREAKING CHANGE footer for
# the same reason — on a major, both are what semantic-release reads.
PR_URL=$(gh pr create \
--title "$COMMIT_SUBJECT" \
--body "$(cat <<EOF
Automated update: plcc-ng has a new release (${LATEST}).

CI will build and test both images on this PR. Merge if green — merging triggers the release workflow, which publishes new versioned images automatically.

PyPI release: https://pypi.org/project/plcc-ng/${LATEST}/

${COMMIT_BODY}
EOF
)" \
--base main \
--head "$BRANCH"
--head "$BRANCH")

echo "Opened $PR_URL"

# Cosmetic only — never fail the run over a missing label or scope.
gh pr edit "$PR_URL" --add-label automated \
|| echo "::warning::Could not apply the 'automated' label to $PR_URL"
69 changes: 66 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,82 @@ on:
permissions: {}

jobs:
# Building both images takes tens of minutes, and most of that is wasted on
# PRs that touch only workflows or docs. Every job below still runs and still
# posts its check — only the expensive steps are guarded. Skipping the jobs
# themselves would leave required status checks unreported and block merges.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
feature: ${{ steps.filter.outputs.feature }}
images: ${{ steps.filter.outputs.images }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # base..head diff needs both endpoints

- name: Detect which parts of the build a change can affect
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
echo "Changed files:"
echo "$CHANGED"

# if/else rather than `match ... && VAR=true`: this runs under bash -e,
# where a trailing failed && list would abort the step.
match() { echo "$CHANGED" | grep -qE "$1"; }

# The feature and its own tests. test/plcc-ng/** drives
# `devcontainer features test` and nothing else.
if match '^(src|test/plcc-ng)/|^\.github/workflows/ci\.yml$'; then
FEATURE=true
else
FEATURE=false
fi

# Image contents and how they are built/verified. build-and-test.sh
# copies only test/smoke-test.sh into the image, so the rest of
# test/ cannot change a build result.
if match '^(images|src|scripts)/|^test/smoke-test\.sh$|^\.github/workflows/ci\.yml$'; then
IMAGES=true
else
IMAGES=false
fi

echo "feature=$FEATURE" >> "$GITHUB_OUTPUT"
echo "images=$IMAGES" >> "$GITHUB_OUTPUT"
echo "=> feature tests: $FEATURE | image builds: $IMAGES"

test-feature:
needs: changes
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
if: needs.changes.outputs.feature == 'true'
uses: actions/checkout@v4

- name: Install devcontainer CLI
if: needs.changes.outputs.feature == 'true'
run: npm install -g @devcontainers/cli

- name: Test plcc-ng feature
if: needs.changes.outputs.feature == 'true'
run: |
devcontainer features test \
--project-folder . \
--features plcc-ng \
--base-image mcr.microsoft.com/devcontainers/base:ubuntu

build:
needs: test-feature
needs: [changes, test-feature]
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -39,37 +95,44 @@ jobs:
variant: [plcc-ng, plcc-ng-full]
steps:
- name: Checkout
if: needs.changes.outputs.images == 'true'
uses: actions/checkout@v4

- name: Free disk space for large image build
if: matrix.variant == 'plcc-ng-full'
if: needs.changes.outputs.images == 'true' && matrix.variant == 'plcc-ng-full'
run: sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/hostedtoolcache/CodeQL

- name: Log in to GHCR
if: needs.changes.outputs.images == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Install devcontainer CLI
if: needs.changes.outputs.images == 'true'
run: npm install -g @devcontainers/cli

- name: Build image and run smoke test
if: needs.changes.outputs.images == 'true'
run: |
bash scripts/build-and-test.sh "${{ matrix.variant }}" \
"ghcr.io/ourplcc/devcontainers/${{ matrix.variant }}:pr-${{ github.event.pull_request.number }}"

- name: Push PR image
if: needs.changes.outputs.images == 'true'
run: docker push "ghcr.io/ourplcc/devcontainers/${{ matrix.variant }}:pr-${{ github.event.pull_request.number }}"

comment:
needs: build
needs: [changes, build]
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment image tags on PR
# No images were built, so there are no tags to advertise.
if: needs.changes.outputs.images == 'true'
uses: actions/github-script@v7
with:
script: |
Expand Down
7 changes: 6 additions & 1 deletion test/plcc-ng/pinned_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ set -e

source dev-container-features-test-lib

# Must match the version pinned in scenarios.json. Both are updated together
# by .github/workflows/check-plcc-ng-release.yml — keep this line's shape
# (EXPECTED_VERSION="<version>") intact so that update keeps working.
EXPECTED_VERSION="1.0.0"

check "plcc-version runs" plcc-version
check "exact version installed" bash -c \
"/usr/local/pipx/venvs/plcc-ng/bin/python -m pip show plcc-ng | grep -q '^Version: 1.0.0$'"
"/usr/local/pipx/venvs/plcc-ng/bin/python -m pip show plcc-ng | grep -q '^Version: ${EXPECTED_VERSION}\$'"

reportResults
Loading