1"use strict";(self.webpackChunkjohnnyreilly_com=self.webpackChunkjohnnyreilly_com||[]).push([["23330"],{14779(e,n,i){i.r(n),i.d(n,{assets:()=>l,contentTitle:()=>r,default:()=>u,frontMatter:()=>s,metadata:()=>t,toc:()=>d});var t=i(24320),a=i(74848),o=i(28453);let s={slug:"surfacing-azure-pipelines-build-info-in-an-aspnet-react-app",title:"Azure Pipelines Build Info in an ASP.NET React app",authors:"johnnyreilly",date:new Date("2021-01-29T00:00:00.000Z"),image:"./about-page.png",tags:["azure pipelines"],hide_table_of_contents:!1,description:"Surface build metadata using Azure Pipelines and ASP.NET for both client and server builds in your app with this tutorial.",atUri:"at://did:plc:yy3apqjlms24kso7ahn7lbmb/site.standard.document/3mr5bsnnae42a"},r,l={image:i(18856).A,authorsImageUrls:[void 0]},d=[{value:"Putting build info into <code>azure-pipelines.yml</code>",id:"putting-build-info-into-azure-pipelinesyml",level:2},{value:"Surfacing the server build info",id:"surfacing-the-server-build-info",level:2},{value:"Surfacing the client build info",id:"surfacing-the-client-build-info",level:2}];function c(e){let n={a:"a",code:"code",h2:"h2",img:"img",li:"li",p:"p",pre:"pre",ul:"ul",...(0,o.R)(),...e.components};return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.p,{children:'How do you answer the question: "what version of my application is running in Production right now?" This post demonstrates how to surface the build metadata that represents the version of your app, from your app using Azure Pipelines and ASP.NET.'}),"\n",(0,a.jsxs)(n.p,{children:["Many is the time where I've been pondering over why something isn't working as expected and burned a disappointing amount of time before realising that I'm playing with an old version of an app. Wouldn't it be great give our app a way to say: \"Hey! I'm version 1.2.3.4 of your app; built from this commit hash, I was built on Wednesday, I was the nineth build that day and I was built from the ",(0,a.jsx)(n.code,{children:"main"})," branch. And I'm an Aries.\" Or something like that."]}),"\n",(0,a.jsxs)(n.p,{children:["This post was inspired by ",(0,a.jsx)(n.a,{href:"https://www.hanselman.com/blog/adding-a-git-commit-hash-and-azure-devops-build-number-and-build-id-to-an-aspnet-website",children:"Scott Hanselman's similar post on the topic"}),". Ultimately this ended up going in a fairly different direction and so seemed worthy of a post of its own."]}),"\n",(0,a.jsxs)(n.p,{children:['A particular difference is that this is targeting SPAs. Famously, cache invalidation is hard. It\'s possible for the HTML/JS/CSS of your app to be stale due to aggressive caching. So we\'re going to make it possible to see build information for both when the SPA (or "client") is built, as well as when the .NET app (or "server") is built. We\'re using a specific type of SPA here; a ',(0,a.jsx)(n.a,{href:"https://reactjs.org/",children:"React"})," SPA built with ",(0,a.jsx)(n.a,{href:"https://www.typescriptlang.org/",children:"TypeScript"})," and ",(0,a.jsx)(n.a,{href:"https://material-ui.com/",children:"Material UI"}),", however the principles here are general; you could surface this up any which way you choose."]}),"\n",(0,a.jsxs)(n.h2,{id:"putting-build-info-into-azure-pipelinesyml",children:["Putting build info into ",(0,a.jsx)(n.code,{children:"azure-pipelines.yml"})]}),"\n",(0,a.jsxs)(n.p,{children:["The first thing we're going to do is to inject our build details into two identical ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," files; one that sits in the server codebase and which will be used to drive the server build information, and one that sits in the client codebase to drive the client equivalent. They'll end up looking something like this:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-json",children:'{\n "buildNumber": "20210130.1",\n "buildId": "123456",\n "branchName": "main",\n "commitHash": "7089620222c30c1ad88e4b556c0a7908ddd34a8e"\n}\n'})}),"\n",(0,a.jsxs)(n.p,{children:["We generate this by adding the following ",(0,a.jsx)(n.code,{children:"yml"})," to the beginning of our ",(0,a.jsx)(n.code,{children:"azure-pipelines.yml"})," (crucially before the client or server build take place):"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-yml",children:'- script: |\n echo
1-e -n "{\\"buildNumber\\":\\"$(Build.BuildNumber)\\",\\"buildId\\":\\"$(Build.BuildId)\\",\\"branchName\\":\\"$(Build.SourceBranchName)\\",\\"commitHash\\":\\"$(Build.SourceVersion)\\"}" > "$(Build.SourcesDirectory)/src/client-app/src/buildinfo.json"\n echo -e -n "{\\"buildNumber\\":\\"$(Build.BuildNumber)\\",\\"buildId\\":\\"$(Build.BuildId)\\",\\"branchName\\":\\"$(Build.SourceBranchName)\\",\\"commitHash\\":\\"$(Build.SourceVersion)\\"}" > "$(Build.SourcesDirectory)/src/server-app/Server/buildinfo.json"\n displayName: "emit build details as JSON"\n failOnStderr: true\n'})}),"\n",(0,a.jsxs)(n.p,{children:["As you can see, we're placing the following variables that are available at build time in Azure Pipelines, into the ",(0,a.jsx)(n.code,{children:"buildinfo.json"}),":"]}),"\n",(0,a.jsxs)(n.ul,{children:["\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.code,{children:"BuildNumber"})," - The name of the completed build; which usually takes the form of a date in the ",(0,a.jsx)(n.code,{children:"yyyyMMdd"})," format, suffixed by ",(0,a.jsx)(n.code,{children:".x"})," where ",(0,a.jsx)(n.code,{children:"x"})," is a number that increments representing the number of builds that have taken place on the given day."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.code,{children:"BuildId"})," - The ID of the record for the completed build."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.code,{children:"SourceVersion"})," - This is the commit hash of the source code in Git"]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.code,{children:"SourceBranchName"})," - The name of the branch in Git."]}),"\n"]}),"\n",(0,a.jsxs)(n.p,{children:[(0,a.jsx)(n.a,{href:"https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services",children:"There's many variables available in Azure Pipelines that can be used"})," - we've picked out the ones most interesting to us."]}),"\n",(0,a.jsx)(n.h2,{id:"surfacing-the-server-build-info",children:"Surfacing the server build info"}),"\n",(0,a.jsxs)(n.p,{children:["Our pipeline is dropping the ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," over pre-existing stub ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," files in both our client and server codebases. The stub files look like this:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-json",children:'{\n "buildNumber": "yyyyMMdd.x",\n "buildId": "xxxxxx",\n "branchName": "",\n "commitHash": "LOCAL_BUILD"\n}\n'})}),"\n",(0,a.jsxs)(n.p,{children:["In our .NET app, the ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," file has been dropped in the root of the app. And as luck would have it, all JSON files are automatically included in a .NET build and so it will be available at runtime. We want to surface this file through an API, and we also want to use it to stamp details into our logs."]}),"\n",(0,a.jsx)(n.p,{children:"So we need to parse the file, and for that we'll use this:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-cs",children:'using System;\nusing System.IO;\nusing System.Text.Json;\n\nnamespace Server {\n public record BuildInfo(string BranchName, string BuildNumber, string BuildId, string CommitHash);\n\n public static class AppVersionInfo {\n private const string _buildFileName = "buildinfo.json";\n private static BuildInfo _fileBuildInfo = new(\n BranchName: "",\n BuildNumber: DateTime.UtcNow.ToString("yyyyMMdd") + ".0",\n BuildId: "xxxxxx",\n CommitHash: $"Not yet initialised - call {nameof(InitialiseBuildInfoGivenPath)}"\n );\n\n public static void InitialiseBuildInfoGivenPath(string path) {\n var buildFilePath = Path.Combine(path, _buildFileName);\n if (File.Exists(buildFilePath)) {\n try {\n var buildInfoJson = File.ReadAllText(buildFilePath);\n var buildInfo = JsonSerializer.Deserialize<BuildInfo>(buildInfoJson, new JsonSerializerOptions {\n PropertyNamingPolicy = JsonNamingPolicy.CamelCase\n });\n if (buildInfo == null) throw new Exception($"Failed to deserialise {_buildFileName}");\n\n _fileBuildInfo = buildInfo;\n } catch (Exception) {\n _fileBuildInfo = new BuildInfo(\n BranchName: "",\n BuildNumber: DateTime.UtcNow.ToString("yyyyMM
1dd") + ".0",\n BuildId: "xxxxxx",\n CommitHash: "Failed to load build info from buildinfo.json"\n );\n }\n }\n }\n\n public static BuildInfo GetBuildInfo() => _fileBuildInfo;\n }\n}\n'})}),"\n",(0,a.jsxs)(n.p,{children:["The above code reads the ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," file and deserialises it into a ",(0,a.jsx)(n.code,{children:"BuildInfo"})," record which is then surfaced up by the ",(0,a.jsx)(n.code,{children:"GetBuildInfo"})," method. We initialise this at the start of our ",(0,a.jsx)(n.code,{children:"Program.cs"})," like so:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-cs",children:"public static int Main(string[] args) {\n AppVersionInfo.InitialiseBuildInfoGivenPath(Directory.GetCurrentDirectory());\n // Now we're free to call AppVersionInfo.GetBuildInfo()\n // ....\n}\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Now we need a controller to surface this information up. We'll add ourselves a ",(0,a.jsx)(n.code,{children:"BuildInfoController.cs"}),":"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-cs",children:'using Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\nnamespace Server.Controllers {\n [ApiController]\n public class BuildInfoController : ControllerBase {\n [AllowAnonymous]\n [HttpGet("api/build")]\n public BuildInfo GetBuild() => AppVersionInfo.GetBuildInfo();\n }\n}\n'})}),"\n",(0,a.jsxs)(n.p,{children:["This exposes an ",(0,a.jsx)(n.code,{children:"api/build"})," endpoint in our .NET app that, when hit, will display the following JSON:"]}),"\n",(0,a.jsx)(n.p,{children:(0,a.jsx)(n.img,{alt:"screenshot of api/build output",src:`https://res.cloudinary.com/priou/image/fetch/f_auto,q_auto,w_auto,dpr_auto/https://johnnyreilly.com${i(96042).A}`,width:"1050",height:"248",loading:"eager",fetchpriority:"high"})}),"\n",(0,a.jsx)(n.h2,{id:"surfacing-the-client-build-info",children:"Surfacing the client build info"}),"\n",(0,a.jsx)(n.p,{children:"Our server now lets the world know which version it is running and this is tremendous. Now let's make our client do the same."}),"\n",(0,a.jsxs)(n.p,{children:["Very little is required to achieve this. Again we have a ",(0,a.jsx)(n.code,{children:"buildinfo.json"})," sat in the root of our codebase. We're able to import it as a module in TypeScript because we've set the following property in our ",(0,a.jsx)(n.code,{children:"tsconfig.json"}),":"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-json",children:'"resolveJsonModule": true,\n'})}),"\n",(0,a.jsx)(n.p,{children:"As a consequence, consumption is as simple as:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-ts",children:"import clientBuildInfo from './buildinfo.json';\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Which provides us with a ",(0,a.jsx)(n.code,{children:"clientBuildInfo"})," which TypeScript automatically derives as this type:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-ts",children:"type ClientBuildInfo = {\n buildNumber: string;\n buildId: string;\n branchName: string;\n commitHash: string;\n};\n"})}),"\n",(0,a.jsxs)(n.p,{children:['How you choose to use that information is entirely your choice. We\'re going to add ourselves an "about" screen in our app, which displays both client info (loaded using the mechanism above) and server info (',(0,a.jsx)(n.code,{children:"fetch"}),"ed from the ",(0,a.jsx)(n.code,{children:"/api/build"})," endpoint)."]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-tsx",children:'import {\n Card,\n CardContent,\n CardHeader,\n createStyles,\n Grid,\n makeStyles,\n Theme,\n Typography,\n Zoom,\n} from \'@material-ui/core\';\nimport React from \'react\';\nimport clientBuildInfo from \'../../buildinfo.json\';\nimport { projectsPurple } from \'../shared/colors\';\nimport { Loading } from \'../shared/Loading\';\nimport { TransitionContainer } from \'../shared/TransitionContainer\';\n\nconst useStyles = (cardColor: string) =>\n makeStyles((theme: Theme) =>\n createStyles({\n card: {\n padding: theme.spacing(0),\n backgroundColor: cardColor,\n color: theme.palette.common.white,\n minHeight: theme.spacing(28),\n },\n avatar: {\n backgroundColor: theme.palette.getContrastText(cardColor),\n color: cardColor,\n },\n main: {\n padding: theme.spacing(2),\n },\n }),\n )();\n\ntype Styles = ReturnType<typeof useStyles>;\n\nconst AboutPage: React.FC = () => {\n const [serverBuildInfo, setServerBuildInfo] =\n React.useState<typeof clientBuildInfo>();\n\n React.useEffect(() =>
1 {\n fetch(\'/api/build\')\n .then((response) => response.json())\n .then(setServerBuildInfo);\n }, []);\n\n const classes = useStyles(projectsPurple);\n\n return (\n <TransitionContainer>\n <Grid container spacing={3}>\n <Grid item xs={12} sm={12} container alignItems="center">\n <Grid item>\n <Typography variant="h4" component="h1">\n About\n </Typography>\n </Grid>\n </Grid>\n </Grid>\n <Grid container spacing={1}>\n <BuildInfo\n classes={classes}\n title="Client Version"\n {...clientBuildInfo}\n />\n </Grid>\n <br />\n <Grid container spacing={1}>\n {serverBuildInfo ? (\n <BuildInfo\n classes={classes}\n title="Server Version"\n {...serverBuildInfo}\n />\n ) : (\n <Loading />\n )}\n </Grid>\n </TransitionContainer>\n );\n};\n\ninterface Props {\n classes: Styles;\n title: string;\n branchName: string;\n buildNumber: string;\n buildId: string;\n commitHash: string;\n}\n\nconst BuildInfo: React.FC<Props> = ({\n classes,\n title,\n branchName,\n buildNumber,\n buildId,\n commitHash,\n}) => (\n <Zoom mountOnEnter unmountOnExit in={true}>\n <Card className={classes.card}>\n <CardHeader title={title} />\n <CardContent className={classes.main}>\n <Typography variant="body1" component="p">\n <b>Build Number</b> {buildNumber}\n </Typography>\n <Typography variant="body1" component="p">\n <b>Build Id</b> {buildId}\n </Typography>\n <Typography variant="body1" component="p">\n <b>Branch Name</b> {branchName}\n </Typography>\n <Typography variant="body1" component="p">\n <b>Commit Hash</b> {commitHash}\n </Typography>\n </CardContent>\n </Card>\n </Zoom>\n);\n\nexport default AboutPage;\n'})}),"\n",(0,a.jsx)(n.p,{children:"When the above page is viewed it looks like this:"}),"\n",(0,a.jsx)(n.p,{children:(0,a.jsx)(n.img,{alt:"screenshot of our web app surfacing up the build information",src:`https://res.cloudinary.com/priou/image/fetch/f_auto,q_auto,w_auto,dpr_auto/https://johnnyreilly.com${i(22187).A}`,width:"1050",height:"1056",loading:"lazy"})}),"\n",(0,a.jsx)(n.p,{children:"And that's it! Our app is clearly telling us what version is being run, both on the server and in the client. Thanks to Scott Hanselman for his work which inspired this."})]})}function u(e={}){let{wrapper:n}={...(0,o.R)(),...e.components};return n?(0,a.jsx)(n,{...e,children:(0,a.jsx)(c,{...e})}):c(e)}},18856(e,n,i){i.d(n,{A:()=>t});let t=i.p+"assets/images/about-page-770705599a33cec4e60eb1f6442b1832.png"},22187(e,n,i){i.d(n,{A:()=>t});let t=i.p+"assets/images/about-page-770705599a33cec4e60eb1f6442b1832.png"},96042(e,n,i){i.d(n,{A:()=>t});let t=i.p+"assets/images/api-build-screenshot-361f0ca3aa93a3340fdce01739fd0ef4.png"},28453(e,n,i){i.d(n,{R:()=>s,x:()=>r});var t=i(96540);let a={},o=t.createContext(a);function s(e){let 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(a):e.components||a:s(e.components),t.createElement(o.Provider,{value:n},e.children)}},24320(e){e.exports=JSON.parse('{"permalink":"/surfacing-azure-pipelines-build-info-in-an-aspnet-react-app","editUrl":"https://github.com/johnnyreilly/blog.johnnyreilly.com/edit/main/blog-website/blog/2021-01-29-surfacing-azure-pipelines-build-info-in-an-aspnet-react-app/index.md","source":"@site/blog/2021-01-29-surfacing-azure-pipelines-build-info-in-an-aspnet-react-app/index.md","title":"Azure Pipelines Build Info in an ASP.NET React app","description":"Surface build metadata using Azure Pipelines and ASP.NET for both client and server builds in your app with this tutorial.","date":"2021-01-29T00:00:00.000Z","tags":[{"inline":false,"label":"Azure Pipelines","permalink":"/tags/azure-pipelines","description":"The Azure Pipelines CI / CD service."}],"readingTime":6.64,"hasTruncateMarker":true,"authors":[{"name":"John Reilly","title":"OSS Engineer - TypeScript, Azure, React, Node.js, .NET","url":"https://johnnyreilly.com/about","imageURL":"https://johnnyreilly.com/img/profile-2025.jpg","key":"johnnyreilly","page":null}],"frontMatter":{"slug":"surfacing-azure-pipelines-build-info-in-an-aspnet-react-app","title":"Azure Pipelines Build Info
1in an ASP.NET React app","authors":"johnnyreilly","date":"2021-01-29T00:00:00.000Z","image":"./about-page.png","tags":["azure pipelines"],"hide_table_of_contents":false,"description":"Surface build metadata using Azure Pipelines and ASP.NET for both client and server builds in your app with this tutorial.","atUri":"at://did:plc:yy3apqjlms24kso7ahn7lbmb/site.standard.document/3mr5bsnnae42a"},"unlisted":false,"prevItem":{"title":"ASP.NET, Serilog and Application Insights","permalink":"/aspnet-serilog-and-application-insights"},"nextItem":{"title":"Azure App Service, Easy Auth and Roles with .NET and Microsoft.Identity.Web","permalink":"/azure-easy-auth-and-roles-with-net-and-microsoft-identity-web"}}')}}]);
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.