1"use strict";(globalThis.webpackChunkazurecertprep=globalThis.webpackChunkazurecertprep||[]).push([[3580],{69243(e,n,s){s.r(n),s.d(n,{assets:()=>c,contentTitle:()=>l,default:()=>p,frontMatter:()=>r,metadata:()=>t,toc:()=>d});const t=JSON.parse('{"id":"az-400/03c-pipeline-fundamentals/challenge-19","title":"Challenge 19: GitHub Actions fundamentals","description":"This challenge focuses on GitHub Actions. Azure DevOps equivalents are noted where relevant.","source":"@site/docs/az-400/03c-pipeline-fundamentals/challenge-19.md","sourceDirName":"az-400/03c-pipeline-fundamentals","slug":"/az-400/03c-pipeline-fundamentals/challenge-19","permalink":"/docs/az-400/03c-pipeline-fundamentals/challenge-19","draft":false,"unlisted":false,"editUrl":"https://github.com/azurecertprep/azurecertprep.github.io/tree/main/docs/az-400/03c-pipeline-fundamentals/challenge-19.md","tags":[],"version":"current","sidebarPosition":1,"frontMatter":{"sidebar_position":1,"title":"Challenge 19: GitHub Actions fundamentals"},"sidebar":"az400Sidebar","previous":{"title":"Challenge 18: Code coverage analysis","permalink":"/docs/az-400/03b-testing-pipelines/challenge-18"},"next":{"title":"Challenge 20: Azure Pipelines YAML","permalink":"/docs/az-400/03c-pipeline-fundamentals/challenge-20"}}');var i=s(74848),o=s(28453),a=s(67970);const r={sidebar_position:1,title:"Challenge 19: GitHub Actions fundamentals"},l="Challenge 19: GitHub Actions fundamentals",c={},d=[{value:"Exam skills mapped",id:"exam-skills-mapped",level:2},{value:"Scenario",id:"scenario",level:2},{value:"Task 1: Create the CI workflow with build and test stages",id:"task-1-create-the-ci-workflow-with-build-and-test-stages",level:2},{value:"Task 2: Add Docker build and push to GitHub Container Registry",id:"task-2-add-docker-build-and-push-to-github-container-registry",level:2},{value:"Task 3: Deploy to Azure App Service with staging and production",id:"task-3-deploy-to-azure-app-service-with-staging-and-production",level:2},{value:"Task 4: Create a composite action for reusable steps",id:"task-4-create-a-composite-action-for-reusable-steps",level:2},{value:"Task 5: Configure secrets and environment variables",id:"task-5-configure-secrets-and-environment-variables",level:2},{value:"Break & fix",id:"break--fix",level:2},{value:"Exercise 1: Fix the failing workflow",id:"exercise-1-fix-the-failing-workflow",level:3},{value:"Exercise 2: Debug the permissions issue",id:"exercise-2-debug-the-permissions-issue",level:3},{value:"Knowledge check",id:"knowledge-check",level:2},{value:"Cleanup",id:"cleanup",level:2}];function u(e){const n={admonition:"admonition",code:"code",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,o.R)(),...e.components},{Details:s}=n;return s||function(e,n){throw new Error("Expected "+(n?"component":"object")+" `"+e+"` to be defined: you likely forgot to import, pass, or provide it.")}("Details",!0),(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.header,{children:(0,i.jsx)(n.h1,{id:"challenge-19-github-actions-fundamentals",children:"Challenge 19: GitHub Actions fundamentals"})}),"\n",(0,i.jsx)(n.admonition,{title:"Platform: GitHub-first",type:"info",children:(0,i.jsx)(n.p,{children:"This challenge focuses on GitHub Actions. Azure DevOps equivalents are noted where relevant."})}),"\n",(0,i.jsx)(n.h2,{id:"exam-skills-mapped",children:"Exam skills mapped"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"Select a deployment automation solution, including GitHub Actions"}),"\n",(0,i.jsx)(n.li,{children:"Develop pipelines by using YAML"}),"\n"]}),"\n",(0,i.jsx)(n.h2,{id:"scenario",children:"Scenario"}),"\n",(0,i.jsx)(n.p,{children:"Contoso Ltd is migrating their CI/CD pipelines from Jenkins to GitHub Actions. Their primary application is a Node.js REST API (Express.js) that is containerized and deployed to Azure App Service. The team needs a complete workflow that handles building, testing, container image creation, and staged deployments."}),"\n",(0,i.jsx)(n.p,{children:"The repository structure:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-text",children:"contoso-api/\n src/\n index.js\n routes/\n middleware/\n tests/\n unit/\n integration/\n Dockerfile\n package.json\n .github/\n workflows/\n actions/\n"})}
1),"\n",(0,i.jsx)(n.h2,{id:"task-1-create-the-ci-workflow-with-build-and-test-stages",children:"Task 1: Create the CI workflow with build and test stages"}),"\n",(0,i.jsxs)(n.p,{children:["Create ",(0,i.jsx)(n.code,{children:".github/workflows/ci-cd.yml"})," with triggers for push to ",(0,i.jsx)(n.code,{children:"main"})," and pull requests:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:'name: Contoso API CI/CD\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n workflow_dispatch:\n inputs:\n environment:\n description: "Target environment"\n required: true\n default: "staging"\n type: choice\n options:\n - staging\n - production\n skip_tests:\n description: "Skip test execution"\n required: false\n type: boolean\n default: false\n\nenv:\n NODE_VERSION: "20.x"\n REGISTRY: ghcr.io\n IMAGE_NAME: ${{ github.repository }}\n\njobs:\n build:\n name: Build and lint\n runs-on: ubuntu-latest\n outputs:\n version: ${{ steps.version.outputs.version }}\n sha_short: ${{ steps.version.outputs.sha_short }}\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n\n - name: Set up Node.js\n uses: actions/setup-node@v4\n with:\n node-version: ${{ env.NODE_VERSION }}\n cache: "npm"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Run linter\n run: npm run lint\n\n - name: Generate version info\n id: version\n run: |\n VERSION=$(node -p "require(\'./package.json\').version")\n SHA_SHORT=$(git rev-parse --short HEAD)\n echo "version=${VERSION}" >> $GITHUB_OUTPUT\n echo "sha_short=${SHA_SHORT}" >> $GITHUB_OUTPUT\n\n - name: Build application\n run: npm run build\n\n - name: Upload build artifact\n uses: actions/upload-artifact@v4\n with:\n name: build-output\n path: dist/\n retention-days: 5\n\n test:\n name: Run tests\n needs: build\n runs-on: ubuntu-latest\n if: ${{ !inputs.skip_tests }}\n strategy:\n matrix:\n test-type: [unit, integration]\n services:\n redis:\n image: redis:7-alpine\n ports:\n - 6379:6379\n options: >-\n --health-cmd "redis-cli ping"\n --health-interval 10s\n --health-timeout 5s\n --health-retries 5\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n\n - name: Set up Node.js\n uses: actions/setup-node@v4\n with:\n node-version: ${{ env.NODE_VERSION }}\n cache: "npm"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Run ${{ matrix.test-type }} tests\n run: npm run test:${{ matrix.test-type }}\n env:\n REDIS_URL: redis://localhost:6379\n CI: true\n\n - name: Upload test results\n if: always()\n uses: actions/upload-artifact@v4\n with:\n name: test-results-${{ matrix.test-type }}\n path: coverage/\n'})}),"\n",(0,i.jsx)(n.h2,{id:"task-2-add-docker-build-and-push-to-github-container-registry",children:"Task 2: Add Docker build and push to GitHub Container Registry"}),"\n",(0,i.jsx)(n.p,{children:"Add a job that builds and pushes the container image:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:" docker:\n name: Build and push container image\n needs: [build, test]\n runs-on: ubuntu-latest\n if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n permissions:\n contents: read\n packages: write\n outputs:\n image_tag: ${{ steps.meta.outputs.tags }}\n image_digest: ${{ steps.push.outputs.digest }}\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n\n - name: Set up Docker Buildx\n uses: docker/setup-buildx-action@v3\n\n - name: Log in to GitHub Container Registry\n uses: docker/login-action@v3\n with:\n registry: ${{ env.REGISTRY }}\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - name: Extract metadata for Docker\n id: meta\n uses: docker/metadata-action@v5\n with:\n images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n tags: |\n type=sha,prefix=\n type=semver,pattern={{version}},value=${{ needs.build.outputs.version }}\n type=raw,value=latest,enable={{is_default_branch}}\n\n - name: Build and push image\n id: push\n uses: docker/build-push-action@v5\n with:\n context: .\n push: true\n tags: ${{ steps.meta.outputs.tags }}\n labels: ${{ steps.meta.outputs.labels }}\n cache-from: type=gha\n cache-to: type=gha,mode=max\n build-args: |\n NODE_VERSION=${{ env.NODE_VERSION }}\n BUILD_SHA=${{ needs.build.outputs.sha_short }}\n"})}),"\n",(0,i.jsx)(n.h2,{id:"task-3-deploy-to-azure-app-service-with-staging-and-production",children:"Task 3: Deploy to Azure App Service with staging and production"}),"\n",(0,i.jsx)(n.p,{children:"Add deployment jobs using environments:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:' deploy-staging:\n name: Deploy to staging\n needs: docker\n runs-on: ubuntu-latest\n environment:\n name: staging\n url: https://contoso-api-staging.azurewebsites.net\n steps:\n - name: Log in to Azure\n uses: azure/login@v2\n with:\n creds: ${{ secrets.AZURE_CREDENTIALS }}\n\n - name: Deploy to Azure App Service (staging slot)\n uses: azure/webapps-deploy@v3\n with:\n app-name: contoso-api\n slot-name: staging\n images: ${{ needs.docker.outputs.image_tag }}\n\n - name: Run smoke tests against staging\n run: |\n for i in {1..10}; do\n STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://contoso-api-staging.azurewebsites.net/health)\n if [ "$STATUS" = "200" ]; then\n echo "Staging is healthy"\n exit 0\n fi\n echo "Attempt $i: Status $STATUS, retrying..."\n sleep 10\n done\n echo "Staging health check failed"\n exit 1\n\n deploy-production:\n name: Deploy to production\n needs: deploy-staging\n runs-on: ubuntu-latest\n environment:\n name: production\n url: https://contoso-api.azurewebsites.net\n steps:\n - name: Log in to Azure\n uses: azure/login@v2\n with:\n creds: ${{ secrets.AZURE_CREDENTIALS }}\n\n - name: Swap staging slot to production\n run: |\n az webapp deployment slot swap \\\n --resource-group contoso-rg \\\n --name contoso-api \\\n --slot staging \\\n --target-slot production\n'})}),"\n",(0,i.jsx)(n.h2,{id:"task-4-create-a-composite-action-for-reusable-steps",children:"Task 4: Create a composite action for reusable steps"}),"\n",(0,i.jsxs)(n.p,{children:["Create ",(0,i.jsx)(n.code,{children:".github/actions/setup-node-project/action.yml"}),":"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:'name: "Setup Node.js project"\ndescription: "Installs Node.js, caches depen
1dencies, and runs npm ci"\n\ninputs:\n node-version:\n description: "Node.js version to use"\n required: false\n default: "20.x"\n working-directory:\n description: "Working directory for npm commands"\n required: false\n default: "."\n\noutputs:\n cache-hit:\n description: "Whether npm cache was hit"\n value: ${{ steps.cache.outputs[\'cache-hit\'] }}\n\nruns:\n using: "composite"\n steps:\n - name: Set up Node.js ${{ inputs.node-version }}\n uses: actions/setup-node@v4\n with:\n node-version: ${{ inputs.node-version }}\n\n - name: Get npm cache directory\n id: npm-cache-dir\n shell: bash\n run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT\n\n - name: Cache npm dependencies\n id: cache\n uses: actions/cache@v4\n with:\n path: ${{ steps[\'npm-cache-dir\'].outputs.dir }}\n key: ${{ runner.os }}-node-${{ inputs.node-version }}-${{ hashFiles(\'**/package-lock.json\') }}\n restore-keys: |\n ${{ runner.os }}-node-${{ inputs.node-version }}-\n\n - name: Install dependencies\n shell: bash\n working-directory: ${{ inputs.working-directory }}\n run: npm ci\n'})}),"\n",(0,i.jsx)(n.p,{children:"Use the composite action in the workflow:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:" - name: Setup project\n uses: ./.github/actions/setup-node-project\n with:\n node-version: ${{ env.NODE_VERSION }}\n"})}),"\n",(0,i.jsx)(n.h2,{id:"task-5-configure-secrets-and-environment-variables",children:"Task 5: Configure secrets and environment variables"}),"\n",(0,i.jsx)(n.p,{children:"Configure the following secrets and variables at the repository and environment levels:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-bash",children:'# Repository secrets (available to all workflows)\ngh secret set AZURE_CREDENTIALS --body \'{"clientId":"...","clientSecret":"...","subscriptionId":"...","tenantId":"..."}\'\n\n# Environment-specific secrets\ngh secret set DB_CONNECTION_STRING --env staging --body "Server=staging-db.database.windows.net;..."\ngh secret set DB_CONNECTION_STRING --env production --body "Server=prod-db.database.windows.net;..."\n\n# Repository variables\ngh variable set APP_NAME --body "contoso-api"\ngh variable set AZURE_RESOURCE_GROUP --body "contoso-rg"\n\n# Environment variables\ngh variable set APP_SERVICE_PLAN --env staging --body "contoso-plan-staging"\ngh variable set APP_SERVICE_PLAN --env production --body "contoso-plan-prod"\n'})}),"\n",(0,i.jsx)(n.h2,{id:"break--fix",children:"Break & fix"}),"\n",(0,i.jsx)(n.h3,{id:"exercise-1-fix-the-failing-workflow",children:"Exercise 1: Fix the failing workflow"}),"\n",(0,i.jsx)(n.p,{children:"The following workflow has errors. Identify and fix them:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:'name: Broken Workflow\n\non:\n push:\n branches: main # ERROR 1: Should be an array [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - name: Set output\n run: echo "result=success" >> $GITHUB_OUTPUT # ERROR 2: Missing id field\n\n - name: Use output\n run: echo ${{ steps.set-output.outputs.result }} # ERROR 3: step id doesn\'t match\n\n deploy:\n needs: build\n runs-on: ubuntu-latest\n environment: production\n steps:\n - name: Deploy\n uses: azure/webapps-deploy@v3\n with:\n app-name: ${{ env.APP_NAME }} # ERROR 4: env context not available, use vars\n images: ${{ needs.build.outputs.image }} # ERROR 5: build job has no outputs defined\n'})}),"\n",(0,i.jsx)(n.p,{children:(0,i.jsx)(n.strong,{children:"Corrected version:"})}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:"name: Fixed Workflow\n\non:\n push:\n branches: [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n outputs:\n image: ${{ steps['set-output'].outputs.result }}\n steps:\n - uses: actions/checkout@v4\n\n - name: Set output\n id: set-output\n run: echo \"result=success\" >> $GITHUB_OUTPUT\n\n - name: Use output\n run: echo ${{ steps['set-output'].outputs.result }}\n\n deploy:\n needs: build\n runs-on: ubuntu-latest\n environment: production\n steps:\n - name: Deploy\n uses: azure/webapps-deploy@v3\n with:\n app-name: ${{ vars.APP_NAME }}\n images: ${{ needs.build.outputs.image }}\n"})}),"\n",(0,i.jsx)(n.h3,{id:"exercise-2-debug-the-permissions-issue",children:"Exercise 2: Debug the permissions issue"}),"\n",(0,i.jsxs)(n.p,{children:["A workflow that pushes to GHCR fails with ",(0,i.jsx)(n.code,{children:"denied: permission_denied"}),". The workflow file contains:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:"permissions:\n contents: read\n"})}),"\n",(0,i.jsxs)(s,{children:[(0,i.jsx)("summary",{children:"Show solution"}),(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.strong,{children:"Fix:"})," Add ",(0,i.jsx)(n.code,{children:"packages: write"})," permission to allow pushing to GHCR:"]}),(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-yaml",children:"permissions:\n contents: read\n packages: write\n"})})]}),"\n",(0,i.jsx)(n.h2,{id:"knowledge-check",children:"Knowledge check"}),"\n",(0,i.jsx)(a.A,{questions:[{question:"In GitHub Actions, what is the correct way to pass data between jobs?",options:["Use environment variables set with 'export'","Write to a shared file in the workspace","Use job outputs with '$GITHUB_OUTPUT' and the 'needs' context","Use repository variables as intermediary storage"],correctIndex:2,explanation:'Job outputs are defined using echo "key=value" >> $GITHUB_OUTPUT in a step with an id, declared in the job\'s outputs section, and consumed
1in downstream jobs using ${{ needs.job_id.outputs.key }}. Environment variables and workspace files do not persist between jobs since each job runs on a fresh runner.'},{question:"Which trigger configuration allows manual workflow execution with custom parameters?",options:["'on: manual'","'on: workflow_dispatch' with 'inputs'","'on: repository_dispatch' with 'inputs'","'on: push' with 'if: github.event.manual'"],correctIndex:1,explanation:"workflow_dispatch allows manual triggering from the GitHub UI or API with typed inputs (string, boolean, choice, environment). repository_dispatch is triggered via the API with a client_payload but does not provide the same UI-driven input experience."},{question:"What is the primary advantage of a composite action over a reusable workflow?",options:["Composite actions can use secrets directly","Composite actions run in the same job, sharing the workspace and runner","Composite actions support matrix strategies","Composite actions can trigger other workflows"],correctIndex:1,explanation:"Composite actions run as steps within the calling job, meaning they share the same workspace, environment variables, and runner. Reusable workflows run as a separate job (or set of jobs) with their own runner instance. This makes composite actions better for grouping related steps that need shared state."},{question:"Which 'permissions' value is required for a workflow to push container images to GitHub Container Registry (ghcr.io)?",options:["'contents: write'","'packages: write'","'deployments: write'","'registry: write'"],correctIndex:1,explanation:"GHCR uses the packages permission scope. The workflow needs packages: write to push images and packages: read to pull them. When permissions is explicitly set, only the listed permissions are granted (principle of least privilege)."}]}),"\n",(0,i.jsx)(n.h2,{id:"cleanup",children:"Cleanup"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-bash",children:"# Remove the test workflow runs (optional)\ngh run list --workflow=ci-cd.yml --limit 5 --json databaseId --jq '.[].databaseId' | \\\n xargs -I {} gh run delete {}\n\n# Delete environment if no longer needed\ngh api --method DELETE repos/{owner}/{repo}/environments/staging\ngh api --method DELETE repos/{owner}/{repo}/environments/production\n\n# Remove GHCR images\ngh api --method DELETE /user/packages/container/contoso-api/versions/{version_id}\n"})})]})}function p(e={}){const{wrapper:n}={...(0,o.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(u,{...e})}):u(e)}},67970(e,n,s){s.d(n,{A:()=>_});var t=s(96540);const i="container_Wc2S",o="question_w8BQ",a="questionText_khaT",r="questionNumber_tNAi",l="options_hoT3",c="option_h5Gz",d="correct_wiKw",u="incorrect_jk7n",p="dimmed_Rvup",h="optionLetter_SgXF",m="optionText_qkBQ",g="checkmark_PyUX",v="crossmark__CNH",b="explanationCorrect_PaNS",x="explanationIncorrect_Ul4c",w="scoreCard_qRIf",f="perfect_Nu3_",k="good_kzz1",j="review_WPHG";var y=s(74848);function _({questions:e}){const[n,s]=(0,t.useState)({}),o=Object.keys(n).length===e.length,a=Object.values(n).filter(Boolean).length,r=(e,n)=>{s(s=>({...s,[e]:n}))};return(0,y.jsxs)("div",{className:i,children:[e.map((e,n)=>(0,y.jsx)(E,{index:n,question:e.question,options:e.options,correctIndex:e.correctAnswer??e.correctIndex,explanation:e.explanation,onAnswer:r},n)),o&&(0,y.jsxs)("div",{className:w,children:[(0,y.jsxs)("strong",{children:["Score: ",a,"/",e.length]}),a===e.length?(0,y.jsx)("span",{className:f,children:" \u2014 Perfect!"}):a>=.75*e.length?(0,y.jsx)("span",{className:k,children:" \u2014 Good job!"}):(0,y.jsx)("span",{className:j,children:" \u2014 Review the explanations above."})]})]})}function E({index:e,question:n,options:s,correctIndex:i,explanation:w,onAnswer:f}){const[k,j]=(0,t.useState)(null),[_,E]=(0,t.useState)(!1);return(0,y.jsxs)("div",{className:o,children:[(0,y.jsxs)("p",{className:a,children:[(0,y.jsxs)("span",{className:r,children:[e+1,"."]})," ",n]}),(0,y.jsx)("div",{className:l,children:s.map((n,s)=>{let t=c;return _&&(t+=s===i?" "+d:s===k?" "+u:" "+p),(0,y.jsxs)("button",{className:t,onClick:()=>(n=>{_||(j(n),E(!0),f(e,n===i))})(s),disabled:_,children:[(0,y.jsx)("span",{className:h,children:String.fromCharCode(65+s)}),(0,y.jsx)("span",{className:m,children:n}),_&&s===i&&(0,y.jsx)("span",{className:g,children:"\u2713"}),_&&s===k&&s!==i&&(0,y.jsx)("span",{className:v,children:"\u2717"})]},s)})}),_&&(0,y.jsxs)("div",{className:k===i?b:x,children:[(0,y.jsx)("strong",{children:k===i?"Correct!":"Incorrect."})," ",w]})]})}},28453(e,n,s){s.d(n,{R:()=>a,x:()=>r});var t=s(96540);const i={},o=t.createContext(i);function a(e){const n=t.useContext(o);return t.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function r(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(i):e.components||i:a(e.components),t.createElement(o.Provider,{value:n},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.