Portfolio Deployment
Content Engineering
Website files are generated by GitHub Workflows on the fly, then deployed to either Netlify or GitHub Pages.
Website files are generated by GitHub Workflows1 on the fly, then deployed to either Netlify2 or GitHub Pages.3
Staging Deployment
.github/workflows/preview-site.yaml4 runs when a pull request (PR) is opened, or when a commit is sent up to a branch connected to a PR, and finally when a PR is merged or closed.
4 beck-chan/portfolio/.github/workflows: preview-site.yaml
When a PR is opened or a commit is sent up to a branch connected to a PR, the updated website files are rendered and deployed to Netlify,5 then a comment is left on the PR with a link to the staging preview site:
preview:
name: Render & deploy preview
runs-on: ubuntu-latest
if: github.event.action != 'closed'
# Set up steps ...
- name: Render static files
run: |
set -o pipefail
quarto render 2>&1 | tee render_errors.log || {
echo "Quarto render failed immediately";
cat render_errors.log;
exit 1;
}
- name: Deploy to Netlify
env:
NETLIFY_PAT: ${{ secrets.NETLIFY_PAT }}
NETLIFY_PROJECT: ${{ secrets.NETLIFY_PROJECT }}
run: |
set -euo pipefail
if [ ! -d _output ]; then
echo "Quarto output directory _output/ is missing"
exit 1
fi
npx --yes netlify-cli deploy \
--prod \
--dir=_output \
--message="portfolio-pr-${{ github.event.number }}" \
--auth="$NETLIFY_PAT" \
--site="$NETLIFY_PROJECT"
- name: Comment preview link on PR
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- netlify-preview-pr-comment -->';
const body = `${marker}\n### Preview: https://beckchan-staging.netlify.app/`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}When a PR is merged or closed, a different job on the same workflow removes the preview site from the staging URL and replaces the staging files with a redirect to the production site:
cleanup:
name: Clear preview (redirect to production)
runs-on: ubuntu-latest
if: github.event.action == 'closed'
- name: Deploy redirect to GitHub Pages
env:
NETLIFY_PAT: ${{ secrets.NETLIFY_PAT }}
NETLIFY_PROJECT: ${{ secrets.NETLIFY_PROJECT }}
run: |
set -euo pipefail
dir="$(mktemp -d)"
printf '%s\n' '/* https://beck-chan.github.io/ 302' > "$dir/_redirects"
printf '%s\n' \
'<!doctype html>' \
'<meta http-equiv="refresh" content="0;url=https://beck-chan.github.io/">' \
'<title>Redirecting…</title>' \
'<p>No active preview. <a href="https://beck-chan.github.io/">Go to portfolio</a>.</p>' \
> "$dir/index.html"
npx --yes netlify-cli deploy \
--prod \
--dir="$dir" \
--message="portfolio-pr-${{ github.event.number }}-closed" \
--auth="$NETLIFY_PAT" \
--site="$NETLIFY_PROJECT"
- name: Update PR comment
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- netlify-preview-pr-comment -->';
const body = `${marker}\n### Preview cleared\n\nhttps://beckchan-staging.netlify.app/ now redirects to https://beck-chan.github.io/`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}Production Deployment
When a PR is merged into main, the production site is rendered with the production Quarto profile that adds a PostHog tracking snippet6 by .github/workflows/deploy-site.yaml:7
6 beck-chan/portfolio/assets: posthog-tracking.html
7 beck-chan/portfolio/.github/workflows: deploy-site.yaml
# _quarto-production.yml
project:
type: website
format:
html:
include-in-header: assets/posthog-tracking.html # Set up steps ...
- name: Render static files
run: |
set -o pipefail
quarto render --profile production 2>&1 | tee render_errors.log || {
echo "Quarto render failed immediately";
cat render_errors.log;
exit 1;
}
- name: Copies static files to website
env:
TOKEN: ${{ secrets.PORTFOLIO }}
run: |
set -euo pipefail
if [ ! -d _output ]; then
echo "Quarto output directory _output/ is missing"
exit 1
fi
dest="$(mktemp -d)"
git clone --depth 1 --branch main \
"https://x-access-token:${TOKEN}@github.com/beck-chan/beck-chan.github.io.git" \
"$dest"
rsync -a --delete --exclude '.git' _output/ "$dest/"
cd "$dest"
git config user.email "actions@github.com"
git config user.name "GitHub Actions Bot"
git add -A
if git diff --staged --quiet; then
echo "No changes to deploy"
else
git commit -m "Automated file sync from portfolio"
git push origin main
fiThe rendered _output/ files are copied into beck-chan/beck-chan.github.io8 repository, where GitHub Pages9 automatically serves the site from the main branch.
8 beck-chan: beck-chan.github.io
Release Notes
When a PR is merged into main and tagged with a major, minor, or patch label, .github/workflows/generate-release.yaml10 bumps the latest Semantic Versioning (semver) tag, and publishes a release:11
10 beck-chan/portfolio/.github/workflows: generate-release.yaml
11 beck-chan/portfolio/: Releases
- name: Create tagged GitHub release
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const labels = (pr.labels || []).map((label) => label.name.toLowerCase());
const bump = labels.includes('major')
? 'major'
: labels.includes('minor')
? 'minor'
: labels.includes('patch')
? 'patch'
: null;
if (!bump) {
core.info('No major/minor/patch label on the merged PR; skipping release generation.');
return;
}
if (!pr.merge_commit_sha) {
throw new Error('PR is merged but merge_commit_sha is missing.');
}
const parseSemver = (tag) => {
const match = String(tag).match(/^v?(\d+)\.(\d+)\.(\d+)$/);
return match
? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
: null;
};
const tags = await github.paginate(github.rest.repos.listTags, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
let latest = { major: 0, minor: 0, patch: 0 };
for (const tag of tags) {
const version = parseSemver(tag.name);
if (!version) continue;
const isNewer =
version.major > latest.major ||
(version.major === latest.major && version.minor > latest.minor) ||
(version.major === latest.major && version.minor === latest.minor && version.patch > latest.patch);
if (isNewer) latest = version;
}
if (bump === 'major') {
latest = { major: latest.major + 1, minor: 0, patch: 0 };
} else if (bump === 'minor') {
latest = { major: latest.major, minor: latest.minor + 1, patch: 0 };
} else {
latest = { major: latest.major, minor: latest.minor, patch: latest.patch + 1 };
}
const newTag = `v${latest.major}.${latest.minor}.${latest.patch}`;
if (tags.some((tag) => tag.name === newTag)) {
throw new Error(`Tag ${newTag} already exists.`);
}
const body = pr.body || '';
const notesMatch = body.match(
/##\s*Release notes\s*\r?\n([\s\S]*?)(?=\r?\n##\s|$)/i
);
let notes = notesMatch ? notesMatch[1] : '';
notes = notes.replace(/<!--[\s\S]*?-->/g, '').trim();
if (!notes) {
notes = 'No public-facing release notes.';
}
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: newTag,
name: `Release ${newTag}`,
body: notes,
target_commitish: pr.merge_commit_sha,
draft: false,
prerelease: false,
});
core.info(`Created release ${newTag} from ${bump} label.`);Label precedence matches the PR template (1. major, 2. minor, 3. patch), and release text is extracted from the content under the ## Release notes section in the PR top-level comment:12
Comments are stripped, and an empty section falls back to “No public-facing release notes.” PRs without one of the release-linked labels skip release generation.
Open PRs merging into main must have one of the major, minor, patch, or internal labels. .github/workflows/check-release-label.yaml13 fails the check if no required labels are present, so a merge cannot silently skip release generation:
13 beck-chan/portfolio/.github/workflows: check-release-label.yaml
- name: Require a release label
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const labels = (pr.labels || []).map((label) => label.name.toLowerCase());
const allowed = ['major', 'minor', 'patch', 'internal'];
const found = allowed.filter((name) => labels.includes(name));
if (found.length === 0) {
core.setFailed(
'PR needs a release label: major, minor, or patch to generate a release on merge, or internal to skip release generation.'
);
return;
}
core.info(`Release label present: ${found.join(', ')}`);The check re-runs when labels are added or removed, so adding internal clears the error without another push. internal is the opt-out for PRs that should not create a GitHub release.
Local Fallbacks
I created a justfile14 (like a Makefile, but in Rust) to handle fallback cases locally, in the unlikely event that GitHub Actions is out of commission and I need to manually handle website deployments.
14 beck-chan/portfolio: Justfile
just staging
just staging first runs spellcheck on relevant files and returns any errors:
npx cspell "**/*.qmd" --exclude "_output" --exclude ".quarto" --exclude "poetry/random/*"If no errors are found, the site is rendered with no PostHog tracking and deployed to Netlify, then a link to the staging preview site returned:
just _render
just _netlify _output "$NETLIFY_PROJECT" "$message"
echo "{{staging_url}}"just refresh
just refresh clears the staging site with a replacement redirect to the production site, then returns a link to the staging preview site:
dir="$(mktemp -d)"
printf '%s\n' '/* {{production_url}} 302' > "$dir/_redirects"
printf '%s\n' \
'<!doctype html>' \
'<meta http-equiv="refresh" content="0;url={{production_url}}">' \
'<title>Redirecting…</title>' \
'<p>No active preview. <a href="{{production_url}}">Go to portfolio</a>.</p>' \
> "$dir/index.html"
just _netlify "$dir" "$NETLIFY_PROJECT" "$message"
echo "{{staging_url}}"just production
just production renders the site with PostHog tracking and copies the rendered output to the beck-chan/beck-chan.github.io repository, where GitHub Pages should automatically serve the site from the main branch:
just _render production
just _pages_sync
echo "{{pages_repo_url}}"just deploy
just deploy manually deploys the files in from the main branch of beck-chan/beck-chan.github.io repository to GitHub Pages in the event that GitHub Workflows are out of commission and returns a link to the production site:
command -v gh >/dev/null || { echo "gh is required" >&2; exit 1; }
build_type="$(gh api repos/{{pages_repo}}/pages --jq '.build_type // "legacy"')"
html_url="$(gh api repos/{{pages_repo}}/pages --jq '.html_url')"
branch="$(gh api repos/{{pages_repo}}/pages --jq '.source.branch // empty')"
echo "Pages: ${html_url} (branch=${branch:-unknown}, build_type=${build_type})"
if [ "$build_type" = "workflow" ]; then
echo "GitHub Pages build_type is workflow (Actions). POST /pages/builds will not publish this site." >&2
echo "Use Settings → Pages → Source: Deploy from a branch (legacy) for this command." >&2
exit 1
fi
if [ "$dry" -eq 1 ]; then
echo "Would: POST /repos/{{pages_repo}}/pages/builds"
echo "{{production_url}}"
exit 0
fi
gh api -X POST "repos/{{pages_repo}}/pages/builds" >/dev/null
echo "Latest build:"
gh api "repos/{{pages_repo}}/pages/builds/latest" --jq '{status, commit, created_at, updated_at}'
echo "{{production_url}}"just release
just release interactively generates the next semver tag and notes for the next release.
First we identify the bumped tags from the latest vX.Y.Z tags:
latest_major=0
latest_minor=0
latest_patch=0
while IFS= read -r tag; do
[ -z "$tag" ] && continue
if [[ "$tag" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
if (( major > latest_major \
|| (major == latest_major && minor > latest_minor) \
|| (major == latest_major && minor == latest_minor && patch > latest_patch) )); then
latest_major=$major
latest_minor=$minor
latest_patch=$patch
fi
fi
done < <(gh api --paginate "repos/{{source_repo}}/tags" --jq '.[].name')
major_tag="v$((latest_major + 1)).0.0"
minor_tag="v${latest_major}.$((latest_minor + 1)).0"
patch_tag="v${latest_major}.${latest_minor}.$((latest_patch + 1))"
echo "Latest: v${latest_major}.${latest_minor}.${latest_patch}"
echo "1. major ${major_tag} https://github.com/{{source_repo}}/releases/tag/${major_tag}"
echo "2. minor ${minor_tag} https://github.com/{{source_repo}}/releases/tag/${minor_tag}"
echo "3. patch ${patch_tag} https://github.com/{{source_repo}}/releases/tag/${patch_tag}"User is prompted to select the type of release: 1. major, 2. minor, or 3. patch
read -r -p "Select bump (1/2/3): " choice
case "$choice" in
1) new_tag="$major_tag" ;;
2) new_tag="$minor_tag" ;;
3) new_tag="$patch_tag" ;;
*) echo "Invalid choice: ${choice}" >&2; exit 1 ;;
esac
if gh api --paginate "repos/{{source_repo}}/tags" --jq '.[].name' | grep -Fxq "$new_tag"; then
echo "Tag ${new_tag} already exists." >&2
exit 1
fiUser is prompted to enter the release notes body. An empty body falls back to “No public-facing release notes,” then the PR template footer is appended:
echo "Enter release notes. Finish with a line containing only END."
notes=""
while IFS= read -r line || [ -n "$line" ]; do
[[ "$line" == "END" ]] && break
notes+="${line}"$'\n'
done
notes="$(printf '%s' "$notes" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -z "$notes" ]; then
notes="No public-facing release notes."
fi
body="${notes}
> **Browse the updated portfolio at: [{{production_url}}]({{production_url}})**
[How are releases generated?]({{production_url}}samples/content-engineering/personal/portfolio/deployment.html#release-notes)"After confirmation, a GitHub release is created and the new release URL returned:
echo
echo "----"
echo "Release ${new_tag}"
printf '%s\n' "$body"
echo "----"
read -r -p "Create release? [y/N] " confirm
case "$confirm" in
y|Y) ;;
*) echo "Aborted."; exit 1 ;;
esac
printf '%s\n' "$body" | gh release create "$new_tag" \
--title "Release ${new_tag}" \
--notes-file - \
--target "$(git rev-parse HEAD)"
echo "https://github.com/{{source_repo}}/releases/tag/${new_tag}"
